feat: Add out of the box support for Reolink PTZ (#1982)

- Closes: #1964
This commit is contained in:
Dermot Duffy
2025-03-26 21:26:02 -07:00
committed by GitHub
parent f6bb8001e3
commit b5eee5878a
50 changed files with 953 additions and 679 deletions
+1 -2
View File
@@ -1,7 +1,6 @@
import { HomeAssistant } from '../../ha/types'; import { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize'; import { localize } from '../../localize/localize';
import { EntityRegistryManager } from '../../utils/ha/registry/entity'; import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types';
import { Entity } from '../../utils/ha/registry/entity/types';
import { Camera, CameraInitializationOptions } from '../camera'; import { Camera, CameraInitializationOptions } from '../camera';
import { CameraInitializationError } from '../error'; import { CameraInitializationError } from '../error';
@@ -4,7 +4,7 @@ import { HomeAssistant } from '../../ha/types';
import { canonicalizeHAURL } from '../../utils/ha'; import { canonicalizeHAURL } from '../../utils/ha';
import { BrowseMediaManager } from '../../utils/ha/browse-media/browse-media-manager'; import { BrowseMediaManager } from '../../utils/ha/browse-media/browse-media-manager';
import { BROWSE_MEDIA_CACHE_SECONDS } from '../../utils/ha/browse-media/types'; import { BROWSE_MEDIA_CACHE_SECONDS } from '../../utils/ha/browse-media/types';
import { EntityRegistryManager } from '../../utils/ha/registry/entity'; import { EntityRegistryManager } from '../../utils/ha/registry/entity/types';
import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media'; import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media';
import { ViewMedia } from '../../view/media'; import { ViewMedia } from '../../view/media';
import { RequestCache } from '../cache'; import { RequestCache } from '../cache';
+19
View File
@@ -1,4 +1,6 @@
import { ActionsExecutor } from '../card-controller/actions/types';
import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher'; import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher';
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz';
import { CameraConfig } from '../config/schema/cameras'; import { CameraConfig } from '../config/schema/cameras';
import { localize } from '../localize/localize'; import { localize } from '../localize/localize';
import { HassStateDifference, isTriggeredState } from '../utils/ha'; import { HassStateDifference, isTriggeredState } from '../utils/ha';
@@ -6,6 +8,7 @@ import { Capabilities } from './capabilities';
import { CameraManagerEngine } from './engine'; import { CameraManagerEngine } from './engine';
import { CameraNoIDError } from './error'; import { CameraNoIDError } from './error';
import { CameraEventCallback, CameraProxyConfig } from './types'; import { CameraEventCallback, CameraProxyConfig } from './types';
import { getConfiguredPTZAction } from './utils/ptz';
export interface CameraInitializationOptions { export interface CameraInitializationOptions {
stateWatcher: StateWatcherSubscriptionInterface; stateWatcher: StateWatcherSubscriptionInterface;
@@ -83,6 +86,22 @@ export class Camera {
}; };
} }
public async executePTZAction(
executor: ActionsExecutor,
action: PTZAction,
options?: {
phase?: PTZActionPhase;
preset?: string;
},
): Promise<boolean> {
const configuredAction = getConfiguredPTZAction(this.getConfig(), action, options);
if (configuredAction) {
await executor.executeActions({ actions: configuredAction });
return true;
}
return false;
}
protected _stateChangeHandler = (difference: HassStateDifference): void => { protected _stateChangeHandler = (difference: HassStateDifference): void => {
this._eventCallback?.({ this._eventCallback?.({
cameraID: this.getID(), cameraID: this.getID(),
+1 -1
View File
@@ -3,7 +3,7 @@ import { CameraConfig } from '../config/schema/cameras';
import { HomeAssistant } from '../ha/types'; import { HomeAssistant } from '../ha/types';
import { localize } from '../localize/localize'; import { localize } from '../localize/localize';
import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager'; import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager';
import { EntityRegistryManager } from '../utils/ha/registry/entity'; import { EntityRegistryManager } from '../utils/ha/registry/entity/types';
import { ResolvedMediaCache } from '../utils/ha/resolved-media'; import { ResolvedMediaCache } from '../utils/ha/resolved-media';
import { RecordingSegmentsCache, RequestCache } from './cache'; import { RecordingSegmentsCache, RequestCache } from './cache';
import { CameraManagerEngine } from './engine'; import { CameraManagerEngine } from './engine';
-11
View File
@@ -1,4 +1,3 @@
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz';
import { CameraConfig } from '../config/schema/cameras'; import { CameraConfig } from '../config/schema/cameras';
import { HomeAssistant } from '../ha/types'; import { HomeAssistant } from '../ha/types';
import { ViewMedia } from '../view/media'; import { ViewMedia } from '../view/media';
@@ -128,14 +127,4 @@ export interface CameraManagerEngine {
cameraConfig: CameraConfig, cameraConfig: CameraConfig,
context?: CameraEndpointsContext, context?: CameraEndpointsContext,
): CameraEndpoints | null; ): CameraEndpoints | null;
executePTZAction(
hass: HomeAssistant,
cameraConfig: CameraConfig,
action: PTZAction,
options?: {
phase?: PTZActionPhase;
preset?: string;
},
): Promise<void>;
} }
+54 -4
View File
@@ -1,12 +1,13 @@
import uniq from 'lodash-es/uniq'; import uniq from 'lodash-es/uniq';
import { ActionsExecutor } from '../../card-controller/actions/types';
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher'; import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
import { CameraConfig } from '../../config/schema/cameras'; import { CameraConfig } from '../../config/schema/cameras';
import { HomeAssistant } from '../../ha/types'; import { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize'; import { localize } from '../../localize/localize';
import { PTZCapabilities, PTZMovementType } from '../../types'; import { PTZCapabilities, PTZMovementType } from '../../types';
import { errorToConsole } from '../../utils/basic'; import { errorToConsole } from '../../utils/basic';
import { EntityRegistryManager } from '../../utils/ha/registry/entity'; import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types';
import { Entity } from '../../utils/ha/registry/entity/types';
import { Camera, CameraInitializationOptions } from '../camera'; import { Camera, CameraInitializationOptions } from '../camera';
import { Capabilities } from '../capabilities'; import { Capabilities } from '../capabilities';
import { CameraManagerEngine } from '../engine'; import { CameraManagerEngine } from '../engine';
@@ -57,6 +58,55 @@ export class FrigateCamera extends Camera {
return await super.initialize(options); return await super.initialize(options);
} }
public async executePTZAction(
executor: ActionsExecutor,
action: PTZAction,
options?: {
phase?: PTZActionPhase;
preset?: string;
},
): Promise<boolean> {
if (await super.executePTZAction(executor, action, options)) {
return true;
}
const cameraEntity = this.getConfig().camera_entity;
if ((action === 'preset' && !options?.preset) || !cameraEntity) {
return false;
}
// Awkward translation between card action and service parameters:
// https://github.com/blakeblackshear/frigate-hass-integration/blob/dev/custom_components/frigate/services.yaml
await executor.executeActions({
actions: {
action: 'perform-action',
perform_action: 'frigate.ptz',
data: {
action:
options?.phase === 'stop'
? 'stop'
: action === 'zoom_in' || action === 'zoom_out'
? 'zoom'
: action === 'preset'
? 'preset'
: 'move',
...(options?.phase !== 'stop' && {
argument:
action === 'zoom_in'
? 'in'
: action === 'zoom_out'
? 'out'
: action === 'preset'
? options?.preset
: action,
}),
},
target: { entity_id: cameraEntity },
},
});
return true;
}
protected async _initializeConfig( protected async _initializeConfig(
hass: HomeAssistant, hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager, entityRegistryManager: EntityRegistryManager,
@@ -194,10 +244,10 @@ export class FrigateCamera extends Camera {
// Note: The Frigate integration only supports continuous PTZ movements // Note: The Frigate integration only supports continuous PTZ movements
// (regardless of the actual underlying camera capability). // (regardless of the actual underlying camera capability).
const panTilt: PTZMovementType[] = [ const panTilt: PTZMovementType[] = [
...(ptzInfo.features?.includes('pt') ? ['continuous' as const] : []), ...(ptzInfo.features?.includes('pt') ? [PTZMovementType.Continuous] : []),
]; ];
const zoom: PTZMovementType[] = [ const zoom: PTZMovementType[] = [
...(ptzInfo.features?.includes('zoom') ? ['continuous' as const] : []), ...(ptzInfo.features?.includes('zoom') ? [PTZMovementType.Continuous] : []),
]; ];
const presets = ptzInfo.presets; const presets = ptzInfo.presets;
+1 -42
View File
@@ -4,7 +4,6 @@ import orderBy from 'lodash-es/orderBy';
import throttle from 'lodash-es/throttle'; import throttle from 'lodash-es/throttle';
import uniqWith from 'lodash-es/uniqWith'; import uniqWith from 'lodash-es/uniqWith';
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher'; import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
import { CameraConfig } from '../../config/schema/cameras'; import { CameraConfig } from '../../config/schema/cameras';
import { HomeAssistant } from '../../ha/types'; import { HomeAssistant } from '../../ha/types';
import { import {
@@ -14,7 +13,7 @@ import {
runWhenIdleIfSupported, runWhenIdleIfSupported,
} from '../../utils/basic'; } from '../../utils/basic';
import { getEntityTitle } from '../../utils/ha'; import { getEntityTitle } from '../../utils/ha';
import { EntityRegistryManager } from '../../utils/ha/registry/entity'; import { EntityRegistryManager } from '../../utils/ha/registry/entity/types';
import { ViewMedia } from '../../view/media'; import { ViewMedia } from '../../view/media';
import { ViewMediaClassifier } from '../../view/media-classifier'; import { ViewMediaClassifier } from '../../view/media-classifier';
import { RecordingSegmentsCache, RequestCache } from '../cache'; import { RecordingSegmentsCache, RequestCache } from '../cache';
@@ -1005,44 +1004,4 @@ export class FrigateCameraManagerEngine
...(jsmpeg && { jsmpeg: jsmpeg }), ...(jsmpeg && { jsmpeg: jsmpeg }),
}; };
} }
public async executePTZAction(
hass: HomeAssistant,
cameraConfig: CameraConfig,
action: PTZAction,
options?: {
phase?: PTZActionPhase;
preset?: string;
},
): Promise<void> {
const cameraEntity = cameraConfig.camera_entity;
if (action === 'preset' && !options?.preset) {
return;
}
// Awkward translation between card action and service parameters:
// https://github.com/blakeblackshear/frigate-hass-integration/blob/dev/custom_components/frigate/services.yaml
await hass.callService('frigate', 'ptz', {
entity_id: cameraEntity,
action:
options?.phase === 'stop'
? 'stop'
: action === 'zoom_in' || action === 'zoom_out'
? 'zoom'
: action === 'preset'
? 'preset'
: 'move',
...(options?.phase !== 'stop' && {
argument:
action === 'zoom_in'
? 'in'
: action === 'zoom_out'
? 'out'
: action === 'preset'
? options?.preset
: action,
}),
});
}
} }
@@ -235,16 +235,4 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
} }
: null; : null;
} }
public async executePTZAction(
_hass: HomeAssistant,
_cameraConfig: CameraConfig,
_action: PTZAction,
_options?: {
phase?: PTZActionPhase;
preset?: string;
},
): Promise<void> {
// Pass.
}
} }
+4 -16
View File
@@ -55,7 +55,6 @@ import {
RecordingSegmentsQueryResultsMap, RecordingSegmentsQueryResultsMap,
ResultsMap, ResultsMap,
} from './types.js'; } from './types.js';
import { getConfiguredPTZAction } from './utils/ptz.js';
import { sortMedia } from './utils/sort-media.js'; import { sortMedia } from './utils/sort-media.js';
export class QueryClassifier { export class QueryClassifier {
@@ -789,23 +788,12 @@ export class CameraManager {
preset?: string; preset?: string;
}, },
): Promise<void> { ): Promise<void> {
const cameraConfig = this._store.getCameraConfig(cameraID); const camera = this._store.getCamera(cameraID);
if (!cameraConfig) { if (!camera) {
return; return;
} }
const configuredAction = getConfiguredPTZAction(cameraConfig, action, options); await this._requestLimit.add(() =>
if (configuredAction) { camera.executePTZAction(this._api.getActionsManager(), action, options),
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 this._requestLimit.add(() =>
engine.executePTZAction(hass, cameraConfig, action, options),
); );
} }
} }
+154 -4
View File
@@ -1,10 +1,15 @@
import { ActionsExecutor } from '../../card-controller/actions/types';
import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/ptz';
import { HomeAssistant } from '../../ha/types'; import { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize'; import { localize } from '../../localize/localize';
import { EntityRegistryManager } from '../../utils/ha/registry/entity'; import { PTZCapabilities, PTZMovementType } from '../../types';
import { Entity, EntityRegistryManager } from '../../utils/ha/registry/entity/types';
import { BrowseMediaCamera } from '../browse-media/camera'; import { BrowseMediaCamera } from '../browse-media/camera';
import { Camera, CameraInitializationOptions } from '../camera'; import { Camera, CameraInitializationOptions } from '../camera';
import { Capabilities } from '../capabilities';
import { CameraInitializationError } from '../error'; import { CameraInitializationError } from '../error';
import { CameraProxyConfig } from '../types'; import { CameraProxyConfig } from '../types';
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
interface ReolinkCameraInitializationOptions extends CameraInitializationOptions { interface ReolinkCameraInitializationOptions extends CameraInitializationOptions {
entityRegistryManager: EntityRegistryManager; entityRegistryManager: EntityRegistryManager;
@@ -13,27 +18,138 @@ interface ReolinkCameraInitializationOptions extends CameraInitializationOptions
class ReolinkInitializationError extends CameraInitializationError {} class ReolinkInitializationError extends CameraInitializationError {}
interface PTZActionToButtonEntity {
stop?: string;
left?: string;
right?: string;
up?: string;
down?: string;
zoomIn?: string;
zoomOut?: string;
}
export class ReolinkCamera extends BrowseMediaCamera { export class ReolinkCamera extends BrowseMediaCamera {
protected _channel: number | null = null; protected _channel: number | null = null;
protected _reolinkUniqueID: string | null = null;
protected _ptzButtons: PTZActionToButtonEntity | null = null;
public async initialize(options: ReolinkCameraInitializationOptions): Promise<Camera> { public async initialize(options: ReolinkCameraInitializationOptions): Promise<Camera> {
await super.initialize(options); await super.initialize(options);
this._initializeChannel(); this._initializeChannel();
await this._initializeCapabilities(options.hass, options.entityRegistryManager);
return this; return this;
} }
protected _initializeChannel(): void { protected _initializeChannel(): void {
const uniqueID = this._entity?.unique_id; const uniqueID = this._entity?.unique_id;
const match = uniqueID ? String(uniqueID).match(/(.*)_(?<channel>\d+)/) : null; const match = uniqueID
const channel = match && match.groups?.channel ? Number(match.groups.channel) : null; ? String(uniqueID).match(/(?<uniqueid>.*)_(?<channel>\d+)/)
: null;
if (channel === null) { const channel = match && match.groups?.channel ? Number(match.groups.channel) : null;
const reolinkUniqueID = match?.groups?.uniqueid ?? null;
if (channel === null || reolinkUniqueID === null) {
throw new ReolinkInitializationError( throw new ReolinkInitializationError(
localize('error.camera_initialization_reolink'), localize('error.camera_initialization_reolink'),
this.getConfig(), this.getConfig(),
); );
} }
this._channel = channel; this._channel = channel;
this._reolinkUniqueID = reolinkUniqueID;
}
protected async _initializeCapabilities(
hass: HomeAssistant,
entityRegistry: EntityRegistryManager,
): Promise<void> {
const config = this.getConfig();
const ptzButtonMap = await this._getPTZButtonEntities(hass, entityRegistry);
const configPTZCapabilities = getPTZCapabilitiesFromCameraConfig(this.getConfig());
const reolinkPTZCapabilities = ptzButtonMap
? Object.keys(ptzButtonMap).reduce(
(acc, key) =>
key === 'stop' ? acc : { [key]: [PTZMovementType.Continuous], ...acc },
{},
)
: null;
const combinedPTZCapabilities: PTZCapabilities | null =
configPTZCapabilities || reolinkPTZCapabilities
? {
...reolinkPTZCapabilities,
...configPTZCapabilities,
}
: null;
this._capabilities = new Capabilities(
{
'favorite-events': false,
'favorite-recordings': false,
'remote-control-entity': true,
clips: true,
live: true,
menu: true,
recordings: false,
seek: false,
snapshots: false,
substream: true,
trigger: true,
...(combinedPTZCapabilities && { ptz: combinedPTZCapabilities }),
},
{
disable: config.capabilities?.disable,
disableExcept: config.capabilities?.disable_except,
},
);
this._ptzButtons = ptzButtonMap;
}
protected async _getPTZButtonEntities(
hass: HomeAssistant,
entityRegistry: EntityRegistryManager,
): Promise<PTZActionToButtonEntity | null> {
/* istanbul ignore next: this path cannot be reached as an exception is
thrown in initialize() if this value is not found -- @preserve */
if (!this._reolinkUniqueID) {
return null;
}
const uniqueIDPrefix = `${this._reolinkUniqueID}_${this._channel}_`;
const buttonEntities = await entityRegistry.getMatchingEntities(
hass,
(ent: Entity) =>
ent.config_entry_id === this._entity?.config_entry_id &&
!!ent.unique_id &&
String(ent.unique_id).startsWith(uniqueIDPrefix) &&
!ent.disabled_by &&
ent.entity_id.startsWith('button.'),
);
const capabilityMap = {
_ptz_stop: 'stop',
_ptz_left: 'left',
_ptz_right: 'right',
_ptz_up: 'up',
_ptz_down: 'down',
_ptz_zoom_in: 'zoomIn',
_ptz_zoom_out: 'zoomOut',
};
const buttonMap: PTZActionToButtonEntity = {};
for (const buttonEntity of buttonEntities) {
for (const [uniqueIDSuffix, capability] of Object.entries(capabilityMap)) {
if (
buttonEntity.unique_id &&
String(buttonEntity.unique_id).endsWith(uniqueIDSuffix)
) {
buttonMap[capability] = buttonEntity.entity_id;
}
}
}
return Object.keys(buttonMap).length ? buttonMap : null;
} }
public getChannel(): number | null { public getChannel(): number | null {
@@ -61,4 +177,38 @@ export class ReolinkCamera extends BrowseMediaCamera {
: this._config.proxy.ssl_ciphers, : this._config.proxy.ssl_ciphers,
}; };
} }
public async executePTZAction(
executor: ActionsExecutor,
action: PTZAction,
options?: {
phase?: PTZActionPhase;
preset?: string;
},
): Promise<boolean> {
if (await super.executePTZAction(executor, action, options)) {
return true;
}
const entityID =
options?.phase === 'start'
? this._ptzButtons?.[action]
: options?.phase === 'stop'
? this._ptzButtons?.stop
: null;
if (!entityID) {
return false;
}
await executor.executeActions({
actions: [
{
action: 'perform-action',
perform_action: 'button.press',
target: { entity_id: entityID },
},
],
});
return true;
}
} }
@@ -17,7 +17,6 @@ import { getViewMediaFromBrowseMediaArray } from '../browse-media/utils/browse-m
import { isMediaWithinDates } from '../browse-media/utils/within-dates'; import { isMediaWithinDates } from '../browse-media/utils/within-dates';
import { MemoryRequestCache } from '../cache'; import { MemoryRequestCache } from '../cache';
import { Camera } from '../camera'; import { Camera } from '../camera';
import { Capabilities } from '../capabilities';
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine'; import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
import { CameraManagerReadOnlyConfigStore } from '../store'; import { CameraManagerReadOnlyConfigStore } from '../store';
import { import {
@@ -37,7 +36,6 @@ import {
QueryResultsType, QueryResultsType,
QueryReturnType, QueryReturnType,
} from '../types'; } from '../types';
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
import { ReolinkCamera } from './camera'; import { ReolinkCamera } from './camera';
import { BrowseMediaReolinkCameraMetadata, ReolinkEventQueryResults } from './types'; import { BrowseMediaReolinkCameraMetadata, ReolinkEventQueryResults } from './types';
@@ -144,26 +142,6 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
cameraConfig: CameraConfig, cameraConfig: CameraConfig,
): Promise<Camera> { ): Promise<Camera> {
const camera = new ReolinkCamera(cameraConfig, this, { const camera = new ReolinkCamera(cameraConfig, this, {
capabilities: new Capabilities(
{
'favorite-events': false,
'favorite-recordings': false,
'remote-control-entity': true,
clips: true,
live: true,
menu: true,
recordings: false,
seek: false,
snapshots: false,
substream: true,
trigger: true,
ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined,
},
{
disable: cameraConfig.capabilities?.disable,
disableExcept: cameraConfig.capabilities?.disable_except,
},
),
eventCallback: this._eventCallback, eventCallback: this._eventCallback,
}); });
return await camera.initialize({ return await camera.initialize({
+2 -2
View File
@@ -48,8 +48,8 @@ export const getConfiguredPTZMovementType = (
return continuous || relative return continuous || relative
? [ ? [
...(continuous ? ['continuous' as const] : []), ...(continuous ? [PTZMovementType.Continuous] : []),
...(relative ? ['relative' as const] : []), ...(relative ? [PTZMovementType.Relative] : []),
] ]
: null; : null;
}; };
+11 -21
View File
@@ -1,11 +1,9 @@
import { ActionContext } from 'action'; import { ActionContext } from 'action';
import { z } from 'zod'; import { z } from 'zod';
import { ConditionsTriggerData } from '../../conditions/types.js';
import { import {
ActionConfig, ActionConfig,
Actions, Actions,
ActionsConfig, ActionsConfig,
AuxillaryActionConfig,
} from '../../config/schema/actions/types.js'; } from '../../config/schema/actions/types.js';
import { forwardHaptic } from '../../ha/haptic.js'; import { forwardHaptic } from '../../ha/haptic.js';
import { import {
@@ -16,7 +14,7 @@ import { allPromises } from '../../utils/basic.js';
import { TemplateRenderer } from '../templates/index.js'; import { TemplateRenderer } from '../templates/index.js';
import { CardActionsManagerAPI } from '../types.js'; import { CardActionsManagerAPI } from '../types.js';
import { ActionSet } from './actions/set.js'; import { ActionSet } from './actions/set.js';
import { ActionExecutionRequest } from './types.js'; import { ActionsExecutionRequest, ActionsExecutor } from './types.js';
const INTERACTIONS = ['tap', 'double_tap', 'hold', 'start_tap', 'end_tap'] as const; const INTERACTIONS = ['tap', 'double_tap', 'hold', 'start_tap', 'end_tap'] as const;
export type InteractionName = (typeof INTERACTIONS)[number]; export type InteractionName = (typeof INTERACTIONS)[number];
@@ -30,7 +28,7 @@ const interactionEventSchema = z.object({
detail: interactionSchema, detail: interactionSchema,
}); });
export class ActionsManager { export class ActionsManager implements ActionsExecutor {
protected _api: CardActionsManagerAPI; protected _api: CardActionsManagerAPI;
protected _actionsInFlight: ActionSet[] = []; protected _actionsInFlight: ActionSet[] = [];
protected _actionContext: ActionContext = {}; protected _actionContext: ActionContext = {};
@@ -86,7 +84,7 @@ export class ActionsManager {
// actions). // actions).
actionConfig actionConfig
) { ) {
await this.executeActions(actionConfig, { config }); await this.executeActions({ actions: actionConfig, config });
} }
}; };
@@ -113,7 +111,7 @@ export class ActionsManager {
return; return;
} }
await this.executeActions(action); await this.executeActions({ actions: action });
}; };
/** /**
@@ -121,11 +119,9 @@ export class ActionsManager {
* Card itself (e.g. menu, PTZ controller). * Card itself (e.g. menu, PTZ controller).
*/ */
public handleActionExecutionRequestEvent = async ( public handleActionExecutionRequestEvent = async (
ev: CustomEvent<ActionExecutionRequest>, ev: CustomEvent<ActionsExecutionRequest>,
): Promise<void> => { ): Promise<void> => {
await this.executeActions(ev.detail.action, { await this.executeActions(ev.detail);
config: ev.detail.config,
});
}; };
public async uninitialize(): Promise<void> { public async uninitialize(): Promise<void> {
@@ -133,24 +129,18 @@ export class ActionsManager {
await allPromises(this._actionsInFlight, (actionSet) => actionSet.stop()); await allPromises(this._actionsInFlight, (actionSet) => actionSet.stop());
} }
public async executeActions( public async executeActions(request: ActionsExecutionRequest): Promise<void> {
action: ActionConfig | ActionConfig[],
options?: {
config?: AuxillaryActionConfig;
triggerData?: ConditionsTriggerData;
},
): Promise<void> {
const hass = this._api.getHASSManager().getHASS(); const hass = this._api.getHASSManager().getHASS();
const renderedAction: ActionConfig | ActionConfig[] = const renderedAction: ActionConfig | ActionConfig[] =
hass && this._templateRenderer hass && this._templateRenderer
? (this._templateRenderer.renderRecursively(hass, action, { ? (this._templateRenderer.renderRecursively(hass, request.actions, {
conditionState: this._api.getConditionStateManager().getState(), conditionState: this._api.getConditionStateManager().getState(),
triggerData: options?.triggerData, triggerData: request?.triggerData,
}) as ActionConfig | ActionConfig[]) }) as ActionConfig | ActionConfig[])
: action; : request.actions;
const actionSet = new ActionSet(this._actionContext, renderedAction, { const actionSet = new ActionSet(this._actionContext, renderedAction, {
config: options?.config, config: request.config,
cardID: this._api.getConfigManager().getConfig()?.card_id, cardID: this._api.getConfigManager().getConfig()?.card_id,
}); });
+2 -1
View File
@@ -1,4 +1,5 @@
import { PTZActionConfig } from '../../../config/schema/actions/custom/ptz'; import { PTZActionConfig } from '../../../config/schema/actions/custom/ptz';
import { PTZMovementType } from '../../../types';
import { getPTZTarget, ptzActionToCapabilityKey } from '../../../utils/ptz'; import { getPTZTarget, ptzActionToCapabilityKey } from '../../../utils/ptz';
import { Timer } from '../../../utils/timer'; import { Timer } from '../../../utils/timer';
import { CardActionsAPI } from '../../types'; import { CardActionsAPI } from '../../types';
@@ -66,7 +67,7 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
if ( if (
(capabilityKey && (capabilityKey &&
ptzCapabilities[capabilityKey]?.includes( ptzCapabilities[capabilityKey]?.includes(
this._action.ptz_phase ? 'continuous' : 'relative', this._action.ptz_phase ? PTZMovementType.Continuous : PTZMovementType.Relative,
)) || )) ||
this._action.ptz_action === 'preset' this._action.ptz_action === 'preset'
) { ) {
+8 -2
View File
@@ -1,3 +1,4 @@
import { ConditionsTriggerData } from '../../conditions/types.js';
import { import {
ActionConfig, ActionConfig,
AuxillaryActionConfig, AuxillaryActionConfig,
@@ -10,9 +11,14 @@ export interface Action {
stop(): Promise<void>; stop(): Promise<void>;
} }
export interface ActionExecutionRequest { export interface ActionsExecutionRequest {
action: ActionConfig[] | ActionConfig; actions: ActionConfig[] | ActionConfig;
config?: AuxillaryActionConfig; config?: AuxillaryActionConfig;
triggerData?: ConditionsTriggerData;
}
export interface ActionsExecutor {
executeActions(request: ActionsExecutionRequest): Promise<void>;
} }
export interface TargetedActionContext { export interface TargetedActionContext {
@@ -1,9 +1,9 @@
import { fireAdvancedCameraCardEvent } from '../../../utils/fire-advanced-camera-card-event'; import { fireAdvancedCameraCardEvent } from '../../../utils/fire-advanced-camera-card-event';
import { ActionExecutionRequest } from '../types'; import { ActionsExecutionRequest } from '../types';
export const dispatchActionExecutionRequest = ( export const dispatchActionExecutionRequest = (
element: HTMLElement, element: HTMLElement,
request: ActionExecutionRequest, request: ActionsExecutionRequest,
) => { ) => {
fireAdvancedCameraCardEvent(element, 'action:execution-request', request); fireAdvancedCameraCardEvent(element, 'action:execution-request', request);
}; };
@@ -13,7 +13,7 @@ export interface ActionExecutionRequestEventTarget extends EventTarget {
event: 'advanced-camera-card:action:execution-request', event: 'advanced-camera-card:action:execution-request',
listener: ( listener: (
this: ActionExecutionRequestEventTarget, this: ActionExecutionRequestEventTarget,
ev: CustomEvent<ActionExecutionRequest>, ev: CustomEvent<ActionsExecutionRequest>,
) => void, ) => void,
options?: AddEventListenerOptions | boolean, options?: AddEventListenerOptions | boolean,
): void; ): void;
@@ -26,7 +26,7 @@ export interface ActionExecutionRequestEventTarget extends EventTarget {
event: 'advanced-camera-card:action:execution-request', event: 'advanced-camera-card:action:execution-request',
listener: ( listener: (
this: ActionExecutionRequestEventTarget, this: ActionExecutionRequestEventTarget,
ev: CustomEvent<ActionExecutionRequest>, ev: CustomEvent<ActionsExecutionRequest>,
) => void, ) => void,
options?: boolean | EventListenerOptions, options?: boolean | EventListenerOptions,
): void; ): void;
+1 -1
View File
@@ -75,7 +75,7 @@ export class AutomationsManager {
await this._api await this._api
.getActionsManager() .getActionsManager()
.executeActions(actions, { triggerData: result.triggerData }); .executeActions({ actions, triggerData: result.triggerData });
--this._nestedAutomationExecutions; --this._nestedAutomationExecutions;
}; };
+3 -2
View File
@@ -9,8 +9,9 @@ import {
} from '../utils/ha/registry/device'; } from '../utils/ha/registry/device';
import { import {
createEntityRegistryCache, createEntityRegistryCache,
EntityRegistryManager, EntityRegistryManagerLive,
} from '../utils/ha/registry/entity'; } from '../utils/ha/registry/entity';
import { EntityRegistryManager } from '../utils/ha/registry/entity/types';
import { ResolvedMediaCache } from '../utils/ha/resolved-media'; import { ResolvedMediaCache } from '../utils/ha/resolved-media';
import { ActionsManager } from './actions/actions-manager'; import { ActionsManager } from './actions/actions-manager';
import { AutomationsManager } from './automations-manager'; import { AutomationsManager } from './automations-manager';
@@ -100,7 +101,7 @@ export class CardController
protected _deviceRegistryManager = new DeviceRegistryManager( protected _deviceRegistryManager = new DeviceRegistryManager(
createDeviceRegistryCache(), createDeviceRegistryCache(),
); );
protected _entityRegistryManager = new EntityRegistryManager( protected _entityRegistryManager = new EntityRegistryManagerLive(
createEntityRegistryCache(), createEntityRegistryCache(),
); );
protected _resolvedMediaCache = new ResolvedMediaCache(); protected _resolvedMediaCache = new ResolvedMediaCache();
+1 -1
View File
@@ -69,7 +69,7 @@ export class QueryStringManager {
protected async _executeNonViewRelated(intent: QueryStringViewIntent): Promise<void> { protected async _executeNonViewRelated(intent: QueryStringViewIntent): Promise<void> {
if (intent.other) { if (intent.other) {
await this._api.getActionsManager().executeActions(intent.other); await this._api.getActionsManager().executeActions({ actions: intent.other });
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
import type { CameraManager } from '../camera-manager/manager'; import type { CameraManager } from '../camera-manager/manager';
import type { ConditionStateManager } from '../conditions/state-manager'; import type { ConditionStateManager } from '../conditions/state-manager';
import type { Automation } from '../config/schema/automations'; import type { Automation } from '../config/schema/automations';
import type { EntityRegistryManager } from '../utils/ha/registry/entity'; import type { EntityRegistryManager } from '../utils/ha/registry/entity/types';
import type { ResolvedMediaCache } from '../utils/ha/resolved-media'; import type { ResolvedMediaCache } from '../utils/ha/resolved-media';
import type { ActionsManager } from './actions/actions-manager'; import type { ActionsManager } from './actions/actions-manager';
import type { AutomationsManager } from './automations-manager'; import type { AutomationsManager } from './automations-manager';
+1 -1
View File
@@ -116,7 +116,7 @@ export class MenuController {
if (toggleLessActions.length) { if (toggleLessActions.length) {
dispatchActionExecutionRequest(this._host, { dispatchActionExecutionRequest(this._host, {
action: actions, actions: actions,
config: config, config: config,
}); });
} }
+1 -1
View File
@@ -63,7 +63,7 @@ export class PTZController {
const action = getActionConfigGivenAction(interaction, config); const action = getActionConfigGivenAction(interaction, config);
if (action) { if (action) {
dispatchActionExecutionRequest(this._host, { dispatchActionExecutionRequest(this._host, {
action: action, actions: action,
...(config && { config: config }), ...(config && { config: config }),
}); });
} }
+1 -1
View File
@@ -86,7 +86,7 @@ export class StatusBarController {
} }
dispatchActionExecutionRequest(this._host, { dispatchActionExecutionRequest(this._host, {
action: arrayify(action), actions: arrayify(action),
config: config, config: config,
}); });
} }
+1 -1
View File
@@ -9,7 +9,7 @@ import { HomeAssistant } from '../ha/types.js';
import menuStyle from '../scss/menu.scss'; import menuStyle from '../scss/menu.scss';
import { hasAction } from '../utils/action.js'; import { hasAction } from '../utils/action.js';
import { getEntityTitle } from '../utils/ha/index.js'; import { getEntityTitle } from '../utils/ha/index.js';
import { EntityRegistryManager } from '../utils/ha/registry/entity/index.js'; import { EntityRegistryManager } from '../utils/ha/registry/entity/types.js';
import './icon.js'; import './icon.js';
import './submenu/select-button.js'; import './submenu/select-button.js';
import './submenu/submenu-button'; import './submenu/submenu-button';
+1 -1
View File
@@ -15,7 +15,7 @@ import menuButtonStyle from '../../scss/menu-button.scss';
import { Icon } from '../../types.js'; import { Icon } from '../../types.js';
import { getEntityTitle, isHassDifferent } from '../../utils/ha'; import { getEntityTitle, isHassDifferent } from '../../utils/ha';
import { getEntityStateTranslation } from '../../utils/ha/entity-state-translation.js'; import { getEntityStateTranslation } from '../../utils/ha/entity-state-translation.js';
import { EntityRegistryManager } from '../../utils/ha/registry/entity/index.js'; import { EntityRegistryManager } from '../../utils/ha/registry/entity/types.js';
import '../icon.js'; import '../icon.js';
import './index.js'; import './index.js';
+4 -1
View File
@@ -81,7 +81,10 @@ export interface CardHelpers {
createCardElement(config: LovelaceCardConfig): Promise<LovelaceCardWithEditor>; createCardElement(config: LovelaceCardConfig): Promise<LovelaceCardWithEditor>;
} }
export type PTZMovementType = 'relative' | 'continuous'; export enum PTZMovementType {
Relative = 'relative',
Continuous = 'continuous',
}
export interface PTZCapabilities { export interface PTZCapabilities {
left?: PTZMovementType[]; left?: PTZMovementType[];
+8 -2
View File
@@ -2,7 +2,13 @@ import { HomeAssistant } from '../../../../ha/types.js';
import { errorToConsole } from '../../../basic.js'; import { errorToConsole } from '../../../basic.js';
import { homeAssistantWSRequest } from '../../ws-request.js'; import { homeAssistantWSRequest } from '../../ws-request.js';
import { RegistryCache } from '../cache.js'; import { RegistryCache } from '../cache.js';
import { Entity, EntityList, entityListSchema, entitySchema } from './types.js'; import {
Entity,
EntityList,
entityListSchema,
EntityRegistryManager,
entitySchema,
} from './types.js';
export const createEntityRegistryCache = (): RegistryCache<Entity> => { export const createEntityRegistryCache = (): RegistryCache<Entity> => {
return new RegistryCache<Entity>((entity) => entity.entity_id); return new RegistryCache<Entity>((entity) => entity.entity_id);
@@ -12,7 +18,7 @@ export const createEntityRegistryCache = (): RegistryCache<Entity> => {
// as necessary. Some calls require every entity to be fetched, which may be // as necessary. Some calls require every entity to be fetched, which may be
// non-trivial in size (after which they are cached forever). // non-trivial in size (after which they are cached forever).
export class EntityRegistryManager { export class EntityRegistryManagerLive implements EntityRegistryManager {
protected _cache: RegistryCache<Entity>; protected _cache: RegistryCache<Entity>;
protected _fetchedEntityList = false; protected _fetchedEntityList = false;
+11
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { HomeAssistant } from '../../../../ha/types';
export const entitySchema = z.object({ export const entitySchema = z.object({
config_entry_id: z.string().nullable(), config_entry_id: z.string().nullable(),
@@ -17,3 +18,13 @@ export type Entity = z.infer<typeof entitySchema>;
export const entityListSchema = entitySchema.array(); export const entityListSchema = entitySchema.array();
export type EntityList = z.infer<typeof entityListSchema>; export type EntityList = z.infer<typeof entityListSchema>;
export interface EntityRegistryManager {
getEntity(hass: HomeAssistant, entityID: string): Promise<Entity | null>;
getEntities(hass: HomeAssistant, entityIDs: string[]): Promise<Map<string, Entity>>;
getMatchingEntities(
hass: HomeAssistant,
func: (arg: Entity) => boolean,
): Promise<Entity[]>;
fetchEntityList(hass: HomeAssistant): Promise<void>;
}
@@ -3,8 +3,10 @@ import { mock } from 'vitest-mock-extended';
import { BrowseMediaCamera } from '../../../src/camera-manager/browse-media/camera'; import { BrowseMediaCamera } from '../../../src/camera-manager/browse-media/camera';
import { CameraManagerEngine } from '../../../src/camera-manager/engine'; import { CameraManagerEngine } from '../../../src/camera-manager/engine';
import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher'; import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
import { EntityRegistryManager } from '../../../src/utils/ha/registry/entity'; import {
import { Entity } from '../../../src/utils/ha/registry/entity/types'; Entity,
EntityRegistryManager,
} from '../../../src/utils/ha/registry/entity/types';
import { createCameraConfig, createHASS } from '../../test-utils'; import { createCameraConfig, createHASS } from '../../test-utils';
describe('BrowseMediaCamera', () => { describe('BrowseMediaCamera', () => {
+7 -2
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { Capabilities } from '../../src/camera-manager/capabilities'; import { Capabilities } from '../../src/camera-manager/capabilities';
import { CapabilityKey, PTZCapabilities, capabilityKeys } from '../../src/types'; import {
CapabilityKey,
PTZCapabilities,
PTZMovementType,
capabilityKeys,
} from '../../src/types';
describe('Capabilities', () => { describe('Capabilities', () => {
it('default capabilities', () => { it('default capabilities', () => {
@@ -73,7 +78,7 @@ describe('Capabilities', () => {
it('when set', () => { it('when set', () => {
const ptz: PTZCapabilities = { const ptz: PTZCapabilities = {
left: ['continuous' as const], left: [PTZMovementType.Continuous],
presets: ['1', '2'], presets: ['1', '2'],
}; };
const capabilities = new Capabilities({ const capabilities = new Capabilities({
+15 -50
View File
@@ -8,10 +8,7 @@ import { ReolinkCameraManagerEngine } from '../../src/camera-manager/reolink/eng
import { Engine } from '../../src/camera-manager/types.js'; import { Engine } from '../../src/camera-manager/types.js';
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js'; import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
import { CardWideConfig } from '../../src/config/schema/types.js'; import { CardWideConfig } from '../../src/config/schema/types.js';
import { import { EntityRegistryManager } from '../../src/utils/ha/registry/entity/types.js';
createEntityRegistryCache,
EntityRegistryManager,
} from '../../src/utils/ha/registry/entity/index.js';
import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media'; import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media';
import { import {
createCameraConfig, createCameraConfig,
@@ -19,6 +16,7 @@ import {
createRegistryEntity, createRegistryEntity,
createStateEntity, createStateEntity,
} from '../test-utils'; } from '../test-utils';
import { EntityRegistryManagerMock } from '../utils/ha/registry/entity/mock.js';
vi.mock('../../src/utils/ha/entity-registry'); vi.mock('../../src/utils/ha/entity-registry');
vi.mock('../../src/utils/ha/entity-registry/cache'); vi.mock('../../src/utils/ha/entity-registry/cache');
@@ -28,8 +26,7 @@ const createFactory = (options?: {
cardWideConfig?: CardWideConfig; cardWideConfig?: CardWideConfig;
}): CameraManagerEngineFactory => { }): CameraManagerEngineFactory => {
return new CameraManagerEngineFactory( return new CameraManagerEngineFactory(
options?.entityRegistryManager ?? options?.entityRegistryManager ?? new EntityRegistryManagerMock(),
new EntityRegistryManager(createEntityRegistryCache()),
); );
}; };
@@ -44,15 +41,9 @@ describe('getEngineForCamera()', () => {
it('from auto detection', async () => { it('from auto detection', async () => {
const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
const entityRegistryManager = new EntityRegistryManager( const entityRegistryManager = new EntityRegistryManagerMock([
createEntityRegistryCache(),
);
entityRegistryManager.getEntity = vi
.fn()
.mockResolvedValue(
createRegistryEntity({ entity_id: 'camera.foo', platform: 'frigate' }), createRegistryEntity({ entity_id: 'camera.foo', platform: 'frigate' }),
); ]);
expect( expect(
await createFactory({ await createFactory({
@@ -81,15 +72,9 @@ describe('getEngineForCamera()', () => {
it('from auto detection', async () => { it('from auto detection', async () => {
const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
const entityRegistryManager = new EntityRegistryManager( const entityRegistryManager = new EntityRegistryManagerMock([
createEntityRegistryCache(),
);
entityRegistryManager.getEntity = vi
.fn()
.mockResolvedValue(
createRegistryEntity({ entity_id: 'camera.foo', platform: 'motioneye' }), createRegistryEntity({ entity_id: 'camera.foo', platform: 'motioneye' }),
); ]);
expect( expect(
await createFactory({ await createFactory({
@@ -109,15 +94,9 @@ describe('getEngineForCamera()', () => {
it('from auto detection', async () => { it('from auto detection', async () => {
const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
const entityRegistryManager = new EntityRegistryManager( const entityRegistryManager = new EntityRegistryManagerMock([
createEntityRegistryCache(),
);
entityRegistryManager.getEntity = vi
.fn()
.mockResolvedValue(
createRegistryEntity({ entity_id: 'camera.foo', platform: 'reolink' }), createRegistryEntity({ entity_id: 'camera.foo', platform: 'reolink' }),
); ]);
expect( expect(
await createFactory({ await createFactory({
@@ -137,15 +116,9 @@ describe('getEngineForCamera()', () => {
it('from auto detection', async () => { it('from auto detection', async () => {
const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
const entityRegistryManager = new EntityRegistryManager( const entityRegistryManager = new EntityRegistryManagerMock([
createEntityRegistryCache(),
);
entityRegistryManager.getEntity = vi
.fn()
.mockResolvedValue(
createRegistryEntity({ entity_id: 'camera.foo', platform: 'generic' }), createRegistryEntity({ entity_id: 'camera.foo', platform: 'generic' }),
); ]);
expect( expect(
await createFactory({ await createFactory({
@@ -159,11 +132,7 @@ describe('getEngineForCamera()', () => {
engine: 'auto', engine: 'auto',
webrtc_card: { entity: 'camera.foo' }, webrtc_card: { entity: 'camera.foo' },
}); });
const entityRegistryManager = new EntityRegistryManager( const entityRegistryManager = new EntityRegistryManagerMock();
createEntityRegistryCache(),
);
entityRegistryManager.getEntity = vi.fn().mockResolvedValue(null);
expect( expect(
await createFactory({ await createFactory({
@@ -182,10 +151,7 @@ describe('getEngineForCamera()', () => {
engine: 'auto', engine: 'auto',
webrtc_card: { entity: 'camera.foo' }, webrtc_card: { entity: 'camera.foo' },
}); });
const entityRegistryManager = new EntityRegistryManager( const entityRegistryManager = new EntityRegistryManagerMock();
createEntityRegistryCache(),
);
entityRegistryManager.getEntity = vi.fn().mockResolvedValue(null);
expect( expect(
async () => async () =>
@@ -225,9 +191,8 @@ describe('getEngineForCamera()', () => {
it('should throw error on invalid entity', async () => { it('should throw error on invalid entity', async () => {
const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
const entityRegistryManager = new EntityRegistryManager(createEntityRegistryCache()); const entityRegistryManager = mock<EntityRegistryManager>();
entityRegistryManager.getEntity.mockRejectedValue(new Error());
entityRegistryManager.getEntity = vi.fn().mockRejectedValue(new Error());
await expect( await expect(
createFactory({ createFactory({
+207 -165
View File
@@ -5,11 +5,16 @@ import { FrigateCamera } from '../../../src/camera-manager/frigate/camera';
import { FrigateEventWatcher } from '../../../src/camera-manager/frigate/event-watcher'; import { FrigateEventWatcher } from '../../../src/camera-manager/frigate/event-watcher';
import { getPTZInfo } from '../../../src/camera-manager/frigate/requests'; import { getPTZInfo } from '../../../src/camera-manager/frigate/requests';
import { FrigateEventChange } from '../../../src/camera-manager/frigate/types'; import { FrigateEventChange } from '../../../src/camera-manager/frigate/types';
import { ActionsExecutor } from '../../../src/card-controller/actions/types';
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
import { PTZAction } from '../../../src/config/schema/actions/custom/ptz';
import { CameraTriggerEventType } from '../../../src/config/schema/cameras'; import { CameraTriggerEventType } from '../../../src/config/schema/cameras';
import { EntityRegistryManager } from '../../../src/utils/ha/registry/entity'; import {
import { Entity } from '../../../src/utils/ha/registry/entity/types'; Entity,
EntityRegistryManager,
} from '../../../src/utils/ha/registry/entity/types';
import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils'; import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils';
import { EntityRegistryManagerMock } from '../../utils/ha/registry/entity/mock';
vi.mock('../../../src/camera-manager/frigate/requests'); vi.mock('../../../src/camera-manager/frigate/requests');
@@ -53,8 +58,7 @@ describe('FrigateCamera', () => {
}), }),
mock<CameraManagerEngine>(), mock<CameraManagerEngine>(),
); );
const entityRegistryManager = mock<EntityRegistryManager>(); const entityRegistryManager = new EntityRegistryManagerMock();
entityRegistryManager.getEntity.mockResolvedValue(null);
expect( expect(
async () => async () =>
@@ -74,12 +78,13 @@ describe('FrigateCamera', () => {
}), }),
mock<CameraManagerEngine>(), mock<CameraManagerEngine>(),
); );
const entityRegistryManager = mock<EntityRegistryManager>(); const entityRegistryManager = new EntityRegistryManagerMock([
const entity = createRegistryEntity({ createRegistryEntity({
entity_id: 'camera.front_door',
unique_id: '8c4e19d258359e82bc0cf9d47b021c46:camera:fnt_dr', unique_id: '8c4e19d258359e82bc0cf9d47b021c46:camera:fnt_dr',
platform: 'frigate', platform: 'frigate',
}); }),
entityRegistryManager.getEntity.mockResolvedValue(entity); ]);
await camera.initialize({ await camera.initialize({
hass: createHASS(), hass: createHASS(),
@@ -97,12 +102,13 @@ describe('FrigateCamera', () => {
}), }),
mock<CameraManagerEngine>(), mock<CameraManagerEngine>(),
); );
const entityRegistryManager = mock<EntityRegistryManager>(); const entityRegistryManager = new EntityRegistryManagerMock([
const entity = createRegistryEntity({ createRegistryEntity({
entity_id: 'camera.front_door',
unique_id: '8c4e19d258359e82bc0cf9d47b021c46:WRONG:fnt_dr', unique_id: '8c4e19d258359e82bc0cf9d47b021c46:WRONG:fnt_dr',
platform: 'frigate', platform: 'frigate',
}); }),
entityRegistryManager.getEntity.mockResolvedValue(entity); ]);
await camera.initialize({ await camera.initialize({
hass: createHASS(), hass: createHASS(),
@@ -120,12 +126,13 @@ describe('FrigateCamera', () => {
}), }),
mock<CameraManagerEngine>(), mock<CameraManagerEngine>(),
); );
const entityRegistryManager = mock<EntityRegistryManager>(); const entityRegistryManager = new EntityRegistryManagerMock([
const entity = createRegistryEntity({ createRegistryEntity({
entity_id: 'camera.front_door',
unique_id: '8c4e19d258359e82bc0cf9d47b021c46:camera:fnt_dr', unique_id: '8c4e19d258359e82bc0cf9d47b021c46:camera:fnt_dr',
platform: 'something_else', platform: 'something_else',
}); }),
entityRegistryManager.getEntity.mockResolvedValue(entity); ]);
await camera.initialize({ await camera.initialize({
hass: createHASS(), hass: createHASS(),
@@ -656,6 +663,7 @@ describe('FrigateCamera', () => {
describe('should handle triggers', () => { describe('should handle triggers', () => {
const cameraEntity: Partial<Entity> = { const cameraEntity: Partial<Entity> = {
config_entry_id: 'config_entry_id', config_entry_id: 'config_entry_id',
entity_id: 'camera.front_door',
}; };
const occupancySensorEntityAll: Partial<Entity> = { const occupancySensorEntityAll: Partial<Entity> = {
@@ -674,11 +682,8 @@ describe('FrigateCamera', () => {
describe('should detect motion sensor', () => { describe('should detect motion sensor', () => {
it('without a camera name', async () => { it('without a camera name', async () => {
const entityRegistryManager = mock<EntityRegistryManager>(); const entityRegistryManager = new EntityRegistryManagerMock([
entityRegistryManager.getEntity.mockResolvedValue(
createRegistryEntity(cameraEntity), createRegistryEntity(cameraEntity),
);
entityRegistryManager.getMatchingEntities.mockResolvedValue([
createRegistryEntity(motionSensorEntity), createRegistryEntity(motionSensorEntity),
]); ]);
const camera = new FrigateCamera( const camera = new FrigateCamera(
@@ -702,11 +707,8 @@ describe('FrigateCamera', () => {
}); });
it('with camera entity and name', async () => { it('with camera entity and name', async () => {
const entityRegistryManager = mock<EntityRegistryManager>(); const entityRegistryManager = new EntityRegistryManagerMock([
entityRegistryManager.getEntity.mockResolvedValue(
createRegistryEntity(cameraEntity), createRegistryEntity(cameraEntity),
);
entityRegistryManager.getMatchingEntities.mockResolvedValue([
createRegistryEntity(motionSensorEntity), createRegistryEntity(motionSensorEntity),
]); ]);
const camera = new FrigateCamera( const camera = new FrigateCamera(
@@ -733,43 +735,7 @@ describe('FrigateCamera', () => {
expect(camera.getConfig().triggers.entities).toEqual(['binary_sensor.foo']); expect(camera.getConfig().triggers.entities).toEqual(['binary_sensor.foo']);
}); });
it('with matching entity', async () => {
const entityRegistryManager = mock<EntityRegistryManager>();
entityRegistryManager.getEntity.mockResolvedValue(
createRegistryEntity(cameraEntity),
);
entityRegistryManager.getMatchingEntities.mockResolvedValue([
createRegistryEntity(motionSensorEntity),
]);
const camera = new FrigateCamera(
createCameraConfig({
frigate: {
camera_name: 'front_door',
},
triggers: {
motion: true,
},
}),
mock<CameraManagerEngine>(),
);
const hass = createHASS();
await camera.initialize({
hass: hass,
entityRegistryManager: entityRegistryManager,
stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: mock<FrigateEventWatcher>(),
});
expect(camera.getConfig().triggers.entities).toEqual(['binary_sensor.foo']);
});
it('without matching entity', async () => { it('without matching entity', async () => {
const entityRegistryManager = mock<EntityRegistryManager>();
entityRegistryManager.getEntity.mockResolvedValue(
createRegistryEntity(cameraEntity),
);
entityRegistryManager.getMatchingEntities.mockResolvedValue([]);
const camera = new FrigateCamera( const camera = new FrigateCamera(
createCameraConfig({ createCameraConfig({
frigate: { frigate: {
@@ -784,7 +750,7 @@ describe('FrigateCamera', () => {
const hass = createHASS(); const hass = createHASS();
await camera.initialize({ await camera.initialize({
hass: hass, hass: hass,
entityRegistryManager: entityRegistryManager, entityRegistryManager: new EntityRegistryManagerMock(),
stateWatcher: mock<StateWatcher>(), stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: mock<FrigateEventWatcher>(), frigateEventWatcher: mock<FrigateEventWatcher>(),
}); });
@@ -795,11 +761,8 @@ describe('FrigateCamera', () => {
describe('should detect occupancy sensor', () => { describe('should detect occupancy sensor', () => {
it('without a camera name', async () => { it('without a camera name', async () => {
const entityRegistryManager = mock<EntityRegistryManager>(); const entityRegistryManager = new EntityRegistryManagerMock([
entityRegistryManager.getEntity.mockResolvedValue(
createRegistryEntity(cameraEntity), createRegistryEntity(cameraEntity),
);
entityRegistryManager.getMatchingEntities.mockResolvedValue([
createRegistryEntity(occupancySensorEntityAll), createRegistryEntity(occupancySensorEntityAll),
]); ]);
const camera = new FrigateCamera( const camera = new FrigateCamera(
@@ -822,11 +785,8 @@ describe('FrigateCamera', () => {
}); });
it('without a camera name but with occupancy trigger', async () => { it('without a camera name but with occupancy trigger', async () => {
const entityRegistryManager = mock<EntityRegistryManager>(); const entityRegistryManager = new EntityRegistryManagerMock([
entityRegistryManager.getEntity.mockResolvedValue(
createRegistryEntity(cameraEntity), createRegistryEntity(cameraEntity),
);
entityRegistryManager.getMatchingEntities.mockResolvedValue([
createRegistryEntity(occupancySensorEntityAll), createRegistryEntity(occupancySensorEntityAll),
]); ]);
const camera = new FrigateCamera( const camera = new FrigateCamera(
@@ -848,42 +808,7 @@ describe('FrigateCamera', () => {
expect(camera.getConfig().triggers.entities).toEqual([]); expect(camera.getConfig().triggers.entities).toEqual([]);
}); });
it('with matching entity', async () => {
const entityRegistryManager = mock<EntityRegistryManager>();
entityRegistryManager.getEntity.mockResolvedValue(
createRegistryEntity(cameraEntity),
);
entityRegistryManager.getMatchingEntities.mockResolvedValue([
createRegistryEntity(occupancySensorEntityAll),
]);
const camera = new FrigateCamera(
createCameraConfig({
frigate: {
camera_name: 'front_door',
},
triggers: {
occupancy: true,
},
}),
mock<CameraManagerEngine>(),
);
const hass = createHASS();
await camera.initialize({
hass: hass,
entityRegistryManager: entityRegistryManager,
stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: mock<FrigateEventWatcher>(),
});
expect(camera.getConfig().triggers.entities).toEqual(['binary_sensor.foo']);
});
it('without matching entity', async () => { it('without matching entity', async () => {
const entityRegistryManager = mock<EntityRegistryManager>();
entityRegistryManager.getEntity.mockResolvedValue(
createRegistryEntity(cameraEntity),
);
entityRegistryManager.getMatchingEntities.mockResolvedValue([]);
const camera = new FrigateCamera( const camera = new FrigateCamera(
createCameraConfig({ createCameraConfig({
frigate: { frigate: {
@@ -898,7 +823,7 @@ describe('FrigateCamera', () => {
const hass = createHASS(); const hass = createHASS();
await camera.initialize({ await camera.initialize({
hass: hass, hass: hass,
entityRegistryManager: entityRegistryManager, entityRegistryManager: new EntityRegistryManagerMock(),
stateWatcher: mock<StateWatcher>(), stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: mock<FrigateEventWatcher>(), frigateEventWatcher: mock<FrigateEventWatcher>(),
}); });
@@ -907,11 +832,8 @@ describe('FrigateCamera', () => {
}); });
it('with zones', async () => { it('with zones', async () => {
const entityRegistryManager = mock<EntityRegistryManager>(); const entityRegistryManager = new EntityRegistryManagerMock([
entityRegistryManager.getEntity.mockResolvedValue(
createRegistryEntity(cameraEntity), createRegistryEntity(cameraEntity),
);
entityRegistryManager.getMatchingEntities.mockResolvedValue([
createRegistryEntity({ createRegistryEntity({
...occupancySensorEntityAll, ...occupancySensorEntityAll,
unique_id: '8c4e19d258359e82bc0cf9d47b021c46:occupancy_sensor:zone_all', unique_id: '8c4e19d258359e82bc0cf9d47b021c46:occupancy_sensor:zone_all',
@@ -919,6 +841,7 @@ describe('FrigateCamera', () => {
]); ]);
const camera = new FrigateCamera( const camera = new FrigateCamera(
createCameraConfig({ createCameraConfig({
camera_entity: 'camera.front_door',
frigate: { frigate: {
camera_name: 'front_door', camera_name: 'front_door',
zones: ['zone'], zones: ['zone'],
@@ -941,19 +864,18 @@ describe('FrigateCamera', () => {
}); });
it('with labels', async () => { it('with labels', async () => {
const entityRegistryManager = mock<EntityRegistryManager>(); const entityRegistryManager = new EntityRegistryManagerMock([
entityRegistryManager.getEntity.mockResolvedValue(
createRegistryEntity(cameraEntity), createRegistryEntity(cameraEntity),
);
entityRegistryManager.getMatchingEntities.mockResolvedValue([
createRegistryEntity({ createRegistryEntity({
...occupancySensorEntityAll, ...occupancySensorEntityAll,
unique_id: unique_id:
'8c4e19d258359e82bc0cf9d47b021c46:occupancy_sensor:front_door_car', '8c4e19d258359e82bc0cf9d47b021c46:occupancy_sensor:front_door_car',
}), }),
]); ]);
const camera = new FrigateCamera( const camera = new FrigateCamera(
createCameraConfig({ createCameraConfig({
camera_entity: 'camera.front_door',
frigate: { frigate: {
camera_name: 'front_door', camera_name: 'front_door',
labels: ['car'], labels: ['car'],
@@ -976,74 +898,194 @@ describe('FrigateCamera', () => {
}); });
}); });
it('should filter entities with correct function', async () => { describe('should execute PTZ action', () => {
const entityRegistryManager = mock<EntityRegistryManager>(); it('should ignore preset action without a preset', async () => {
entityRegistryManager.getEntity.mockResolvedValue(
createRegistryEntity(cameraEntity),
);
entityRegistryManager.getMatchingEntities.mockResolvedValue([
createRegistryEntity({
...occupancySensorEntityAll,
}),
]);
const camera = new FrigateCamera( const camera = new FrigateCamera(
createCameraConfig({ createCameraConfig(),
camera_entity: 'camera.foo',
triggers: {
occupancy: true,
},
}),
mock<CameraManagerEngine>(), mock<CameraManagerEngine>(),
); );
const hass = createHASS(); const hass = createHASS();
await camera.initialize({ await camera.initialize({
hass: hass, hass: hass,
entityRegistryManager: entityRegistryManager, entityRegistryManager: mock<EntityRegistryManager>(),
stateWatcher: mock<StateWatcher>(), stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: mock<FrigateEventWatcher>(), frigateEventWatcher: mock<FrigateEventWatcher>(),
}); });
const filterFunc = entityRegistryManager.getMatchingEntities.mock.calls[0][1]; const executor = mock<ActionsExecutor>();
await camera.executePTZAction(executor, 'preset');
expect( expect(executor.executeActions).not.toBeCalled();
filterFunc( });
createRegistryEntity({
config_entry_id: cameraEntity.config_entry_id,
disabled_by: '',
entity_id: 'binary_sensor.foo',
}),
),
).toBeTruthy();
expect( it('should ignore actions with configured action', async () => {
filterFunc( const camera = new FrigateCamera(
createRegistryEntity({ createCameraConfig({
config_entry_id: cameraEntity.config_entry_id, camera_entity: 'camera.office_frigate',
disabled_by: 'user', ptz: {
entity_id: 'binary_sensor.foo', actions_left_start: {
action: 'perform-action',
perform_action: 'button.press',
target: {
entity_id: 'button.foo',
},
},
},
}), }),
), mock<CameraManagerEngine>(),
).toBeFalsy(); );
expect( await camera.initialize({
filterFunc( hass: createHASS(),
createRegistryEntity({ entityRegistryManager: new EntityRegistryManagerMock([
config_entry_id: cameraEntity.config_entry_id, createRegistryEntity({ entity_id: 'camera.office_frigate' }),
disabled_by: '', ]),
entity_id: 'camera.is_not_a_binary_sensor', stateWatcher: mock<StateWatcher>(),
}), frigateEventWatcher: mock<FrigateEventWatcher>(),
), });
).toBeFalsy();
expect( const executor = mock<ActionsExecutor>();
filterFunc( await camera.executePTZAction(executor, 'left', { phase: 'start' });
createRegistryEntity({
config_entry_id: 'not_a_matching_config_entry_id', expect(executor.executeActions).toBeCalledTimes(1);
disabled_by: '', expect(executor.executeActions).toHaveBeenLastCalledWith({
entity_id: 'binary_sensor.foo', actions: {
action: 'perform-action',
perform_action: 'button.press',
target: {
entity_id: 'button.foo',
},
},
});
});
it.each([
['left' as const],
['right' as const],
['up' as const],
['down' as const],
])('should execute action %s', async (action: PTZAction) => {
const camera = new FrigateCamera(
createCameraConfig({
camera_entity: 'camera.office_frigate',
}), }),
), mock<CameraManagerEngine>(),
).toBeFalsy(); );
await camera.initialize({
hass: createHASS(),
entityRegistryManager: new EntityRegistryManagerMock([
createRegistryEntity({ entity_id: 'camera.office_frigate' }),
]),
stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: mock<FrigateEventWatcher>(),
});
const executor = mock<ActionsExecutor>();
await camera.executePTZAction(executor, action, { phase: 'start' });
expect(executor.executeActions).toHaveBeenLastCalledWith({
actions: {
action: 'perform-action',
data: {
action: 'move',
argument: action,
},
perform_action: 'frigate.ptz',
target: {
entity_id: 'camera.office_frigate',
},
},
});
await camera.executePTZAction(executor, action, { phase: 'stop' });
expect(executor.executeActions).toHaveBeenLastCalledWith({
actions: {
action: 'perform-action',
data: {
action: 'stop',
},
perform_action: 'frigate.ptz',
target: {
entity_id: 'camera.office_frigate',
},
},
});
});
it.each([
['zoom_in' as const, 'in' as const],
['zoom_out' as const, 'out' as const],
])('should execute action %s', async (action: PTZAction, zoom: 'in' | 'out') => {
const camera = new FrigateCamera(
createCameraConfig({
camera_entity: 'camera.office_frigate',
}),
mock<CameraManagerEngine>(),
);
await camera.initialize({
hass: createHASS(),
entityRegistryManager: new EntityRegistryManagerMock([
createRegistryEntity({ entity_id: 'camera.office_frigate' }),
]),
stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: mock<FrigateEventWatcher>(),
});
const executor = mock<ActionsExecutor>();
await camera.executePTZAction(executor, action, { phase: 'start' });
expect(executor.executeActions).toHaveBeenLastCalledWith({
actions: {
action: 'perform-action',
data: {
action: 'zoom',
argument: zoom,
},
perform_action: 'frigate.ptz',
target: {
entity_id: 'camera.office_frigate',
},
},
});
});
it('should execute preset', async () => {
const camera = new FrigateCamera(
createCameraConfig({
camera_entity: 'camera.office_frigate',
}),
mock<CameraManagerEngine>(),
);
await camera.initialize({
hass: createHASS(),
entityRegistryManager: new EntityRegistryManagerMock([
createRegistryEntity({ entity_id: 'camera.office_frigate' }),
]),
stateWatcher: mock<StateWatcher>(),
frigateEventWatcher: mock<FrigateEventWatcher>(),
});
const executor = mock<ActionsExecutor>();
await camera.executePTZAction(executor, 'preset', { preset: 'foo' });
expect(executor.executeActions).toHaveBeenLastCalledWith({
actions: {
action: 'perform-action',
data: {
action: 'preset',
argument: 'foo',
},
perform_action: 'frigate.ptz',
target: {
entity_id: 'camera.office_frigate',
},
},
});
});
}); });
}); });
}); });
@@ -7,20 +7,16 @@ import {
} from '../../../src/camera-manager/frigate/media'; } from '../../../src/camera-manager/frigate/media';
import { FrigateEvent, eventSchema } from '../../../src/camera-manager/frigate/types.js'; import { FrigateEvent, eventSchema } from '../../../src/camera-manager/frigate/types.js';
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
import { PTZAction } from '../../../src/config/schema/actions/custom/ptz';
import { CameraConfig } from '../../../src/config/schema/cameras'; import { CameraConfig } from '../../../src/config/schema/cameras';
import { AdvancedCameraCardView } from '../../../src/config/schema/common/const'; import { AdvancedCameraCardView } from '../../../src/config/schema/common/const';
import { RawAdvancedCameraCardConfig } from '../../../src/config/types'; import { RawAdvancedCameraCardConfig } from '../../../src/config/types';
import {
EntityRegistryManager,
createEntityRegistryCache,
} from '../../../src/utils/ha/registry/entity';
import { ViewMedia } from '../../../src/view/media'; import { ViewMedia } from '../../../src/view/media';
import { TestViewMedia, createCameraConfig, createHASS } from '../../test-utils'; import { TestViewMedia, createCameraConfig, createHASS } from '../../test-utils';
import { EntityRegistryManagerMock } from '../../utils/ha/registry/entity/mock';
const createEngine = (): FrigateCameraManagerEngine => { const createEngine = (): FrigateCameraManagerEngine => {
return new FrigateCameraManagerEngine( return new FrigateCameraManagerEngine(
new EntityRegistryManager(createEntityRegistryCache()), new EntityRegistryManagerMock(),
new StateWatcher(), new StateWatcher(),
new RecordingSegmentsCache(), new RecordingSegmentsCache(),
new RequestCache(), new RequestCache(),
@@ -401,101 +397,3 @@ describe('getCameraEndpoints', () => {
}); });
}); });
}); });
describe('executePTZAction', () => {
describe('preset', () => {
it('should reject without preset argument', () => {
const hass = createHASS();
const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' });
createEngine().executePTZAction(hass, cameraConfig, 'preset');
expect(hass.callService).not.toBeCalled();
});
it('should succeed', () => {
const hass = createHASS();
const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' });
createEngine().executePTZAction(hass, cameraConfig, 'preset', {
preset: 'preset-foo',
});
expect(hass.callService).toBeCalledWith('frigate', 'ptz', {
entity_id: 'camera.office',
action: 'preset',
argument: 'preset-foo',
});
});
});
describe('zoom', () => {
describe.each([['zoom_in' as const], ['zoom_out' as const]])(
'%s',
(actionName: PTZAction) => {
it('start', () => {
const hass = createHASS();
const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' });
createEngine().executePTZAction(hass, cameraConfig, actionName);
expect(hass.callService).toBeCalledWith('frigate', 'ptz', {
entity_id: 'camera.office',
action: 'zoom',
argument: actionName === 'zoom_in' ? 'in' : 'out',
});
});
it('stop', () => {
const hass = createHASS();
const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' });
createEngine().executePTZAction(hass, cameraConfig, actionName, {
phase: 'stop',
});
expect(hass.callService).toBeCalledWith('frigate', 'ptz', {
entity_id: 'camera.office',
action: 'stop',
});
});
},
);
});
describe('move', () => {
describe.each([
['left' as const],
['right' as const],
['up' as const],
['down' as const],
])('%s', (actionName: PTZAction) => {
it('start', () => {
const hass = createHASS();
const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' });
createEngine().executePTZAction(hass, cameraConfig, actionName);
expect(hass.callService).toBeCalledWith('frigate', 'ptz', {
entity_id: 'camera.office',
action: 'move',
argument: actionName,
});
});
it('stop', () => {
const hass = createHASS();
const cameraConfig = createCameraConfig({ camera_entity: 'camera.office' });
createEngine().executePTZAction(hass, cameraConfig, actionName, {
phase: 'stop',
});
expect(hass.callService).toBeCalledWith('frigate', 'ptz', {
entity_id: 'camera.office',
action: 'stop',
});
});
});
});
});
@@ -331,10 +331,4 @@ describe('GenericCameraManagerEngine', () => {
}); });
}); });
}); });
it('should execute PTZ action', () => {
const hass = createHASS();
createEngine().executePTZAction(hass, createCameraConfig(), 'left');
expect(hass.callService).not.toBeCalled();
});
}); });
+5 -41
View File
@@ -29,6 +29,7 @@ import { sortMedia } from '../../src/camera-manager/utils/sort-media.js';
import { CardController } from '../../src/card-controller/controller.js'; import { CardController } from '../../src/card-controller/controller.js';
import { CameraConfig } from '../../src/config/schema/cameras.js'; import { CameraConfig } from '../../src/config/schema/cameras.js';
import { HomeAssistant } from '../../src/ha/types.js'; import { HomeAssistant } from '../../src/ha/types.js';
import { PTZMovementType } from '../../src/types.js';
import { ViewMedia } from '../../src/view/media.js'; import { ViewMedia } from '../../src/view/media.js';
import { import {
TestViewMedia, TestViewMedia,
@@ -1069,7 +1070,7 @@ describe('CameraManager', async () => {
snapshots: true, snapshots: true,
ptz: { ptz: {
left: ['continuous'], left: [PTZMovementType.Continuous],
}, },
}), }),
}, },
@@ -1098,25 +1099,10 @@ describe('CameraManager', async () => {
manager.executePTZAction('id', 'left', {}); manager.executePTZAction('id', 'left', {});
expect(engine.executePTZAction).not.toBeCalled(); // No visible action.
}); });
it('without hass', async () => { it('successfully', async () => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
const manager = createCameraManager(api, engine);
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
expect(await manager.initializeCamerasFromConfig()).toBeTruthy();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null);
manager.executePTZAction('id', 'left');
expect(engine.executePTZAction).not.toBeCalled();
});
it('successfully from config', async () => {
const api = createCardAPI(); const api = createCardAPI();
const engine = mock<CameraManagerEngine>(); const engine = mock<CameraManagerEngine>();
const hass = createHASS(); const hass = createHASS();
@@ -1140,29 +1126,7 @@ describe('CameraManager', async () => {
manager.executePTZAction('another', 'left'); manager.executePTZAction('another', 'left');
expect(api.getActionsManager().executeActions).toBeCalledWith(action); expect(api.getActionsManager().executeActions).toBeCalledWith({ actions: action });
expect(engine.executePTZAction).not.toBeCalled();
});
it('successfully from engine', async () => {
const api = createCardAPI();
const engine = mock<CameraManagerEngine>();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const manager = createCameraManager(api, engine);
expect(await manager.initializeCamerasFromConfig()).toBeTruthy();
const action = 'left';
const options = {};
manager.executePTZAction('id', action, options);
expect(engine.executePTZAction).toBeCalledWith(
hass,
expect.anything(),
action,
options,
);
}); });
}); });
+237 -21
View File
@@ -3,12 +3,79 @@ import { mock } from 'vitest-mock-extended';
import { CameraManagerEngine } from '../../../src/camera-manager/engine'; import { CameraManagerEngine } from '../../../src/camera-manager/engine';
import { ReolinkCamera } from '../../../src/camera-manager/reolink/camera'; import { ReolinkCamera } from '../../../src/camera-manager/reolink/camera';
import { CameraProxyConfig } from '../../../src/camera-manager/types'; import { CameraProxyConfig } from '../../../src/camera-manager/types';
import { ActionsExecutor } from '../../../src/card-controller/actions/types';
import { StateWatcher } from '../../../src/card-controller/hass/state-watcher'; import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
import { ProxyConfig } from '../../../src/config/schema/cameras'; import { ProxyConfig } from '../../../src/config/schema/cameras';
import { EntityRegistryManager } from '../../../src/utils/ha/registry/entity'; import { EntityRegistryManagerLive } from '../../../src/utils/ha/registry/entity';
import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils'; import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils';
import { EntityRegistryManagerMock } from '../../utils/ha/registry/entity/mock';
describe('ReolinkCamera', () => { describe('ReolinkCamera', () => {
const cameraEntity = createRegistryEntity({
entity_id: 'camera.office_reolink',
unique_id: '85270002TS7D4RUP_0_main',
platform: 'reolink',
});
const buttonEntityPTZLeft = createRegistryEntity({
entity_id: 'button.office_reolink_ptz_left',
unique_id: '85270002TS7D4RUP_0_ptz_left',
platform: 'reolink',
});
const buttonEntityPTZRight = createRegistryEntity({
entity_id: 'button.office_reolink_ptz_right',
unique_id: '85270002TS7D4RUP_0_ptz_right',
platform: 'reolink',
});
const buttonEntityPTZUp = createRegistryEntity({
entity_id: 'button.office_reolink_ptz_up',
unique_id: '85270002TS7D4RUP_0_ptz_up',
platform: 'reolink',
});
const buttonEntityPTZDown = createRegistryEntity({
entity_id: 'button.office_reolink_ptz_down',
unique_id: '85270002TS7D4RUP_0_ptz_down',
platform: 'reolink',
});
const buttonEntityPTZZoomIn = createRegistryEntity({
entity_id: 'button.office_reolink_ptz_zoom_in',
unique_id: '85270002TS7D4RUP_0_ptz_zoom_in',
platform: 'reolink',
});
const buttonEntityPTZZoomOut = createRegistryEntity({
entity_id: 'button.office_reolink_ptz_zoom_out',
unique_id: '85270002TS7D4RUP_0_ptz_zoom_out',
platform: 'reolink',
});
const buttonEntityPTZStop = createRegistryEntity({
entity_id: 'button.office_reolink_ptz_stop',
unique_id: '85270002TS7D4RUP_0_ptz_stop',
platform: 'reolink',
});
const ptzPopulatedEntityRegistryManager = new EntityRegistryManagerMock([
cameraEntity,
buttonEntityPTZLeft,
buttonEntityPTZRight,
buttonEntityPTZUp,
buttonEntityPTZDown,
buttonEntityPTZZoomIn,
buttonEntityPTZZoomOut,
buttonEntityPTZStop,
// Unrelated button.
createRegistryEntity({
entity_id: 'button.office_reolink_ptz_foo',
unique_id: '85270002TS7D4RUP_0_ptz_foo',
platform: 'reolink',
}),
// Unrelated button without unique_id.
createRegistryEntity({
entity_id: 'button.office_reolink_ptz_bar',
platform: 'reolink',
}),
]);
describe('should initialize config', () => { describe('should initialize config', () => {
describe('should detect channel', () => { describe('should detect channel', () => {
it('without a camera_entity', async () => { it('without a camera_entity', async () => {
@@ -19,7 +86,7 @@ describe('ReolinkCamera', () => {
async () => async () =>
await camera.initialize({ await camera.initialize({
hass: createHASS(), hass: createHASS(),
entityRegistryManager: mock<EntityRegistryManager>(), entityRegistryManager: mock<EntityRegistryManagerLive>(),
stateWatcher: mock<StateWatcher>(), stateWatcher: mock<StateWatcher>(),
}), }),
).rejects.toThrowError('Could not find camera entity'); ).rejects.toThrowError('Could not find camera entity');
@@ -31,17 +98,18 @@ describe('ReolinkCamera', () => {
}); });
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>()); const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
const entityRegistryManager = mock<EntityRegistryManager>(); const entityRegistryManager = new EntityRegistryManagerMock([
const entity = createRegistryEntity({ createRegistryEntity({
entity_id: 'camera.office_reolink',
platform: 'reolink', platform: 'reolink',
}); }),
entityRegistryManager.getEntity.mockResolvedValue(entity); ]);
expect( expect(
async () => async () =>
await camera.initialize({ await camera.initialize({
hass: createHASS(), hass: createHASS(),
entityRegistryManager: entityRegistryManager, entityRegistryManager,
stateWatcher: mock<StateWatcher>(), stateWatcher: mock<StateWatcher>(),
}), }),
).rejects.toThrowError('Could not initialize Reolink camera'); ).rejects.toThrowError('Could not initialize Reolink camera');
@@ -52,19 +120,19 @@ describe('ReolinkCamera', () => {
camera_entity: 'camera.office_reolink', camera_entity: 'camera.office_reolink',
}); });
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>()); const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
const entityRegistryManager = new EntityRegistryManagerMock([
const entityRegistryManager = mock<EntityRegistryManager>(); createRegistryEntity({
const entity = createRegistryEntity({ entity_id: 'camera.office_reolink',
unique_id: 'invalid', unique_id: 'invalid',
platform: 'reolink', platform: 'reolink',
}); }),
entityRegistryManager.getEntity.mockResolvedValue(entity); ]);
expect( expect(
async () => async () =>
await camera.initialize({ await camera.initialize({
hass: createHASS(), hass: createHASS(),
entityRegistryManager: entityRegistryManager, entityRegistryManager,
stateWatcher: mock<StateWatcher>(), stateWatcher: mock<StateWatcher>(),
}), }),
).rejects.toThrowError('Could not initialize Reolink camera'); ).rejects.toThrowError('Could not initialize Reolink camera');
@@ -76,22 +144,71 @@ describe('ReolinkCamera', () => {
camera_entity: 'camera.office_reolink', camera_entity: 'camera.office_reolink',
}); });
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>()); const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
const entityRegistryManager = new EntityRegistryManagerMock([cameraEntity]);
const entityRegistryManager = mock<EntityRegistryManager>();
const entity = createRegistryEntity({
unique_id: '85270002TS7D4RUP_0_main',
platform: 'reolink',
});
entityRegistryManager.getEntity.mockResolvedValue(entity);
await camera.initialize({ await camera.initialize({
hass: createHASS(), hass: createHASS(),
entityRegistryManager: entityRegistryManager, entityRegistryManager,
stateWatcher: mock<StateWatcher>(), stateWatcher: mock<StateWatcher>(),
}); });
expect(camera.getChannel()).toBe(0); expect(camera.getChannel()).toBe(0);
}); });
describe('successfully with PTZ', () => {
it('should find PTZ button entities', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
await camera.initialize({
hass: createHASS(),
entityRegistryManager: ptzPopulatedEntityRegistryManager,
stateWatcher: mock<StateWatcher>(),
});
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
left: ['continuous'],
right: ['continuous'],
up: ['continuous'],
down: ['continuous'],
zoomIn: ['continuous'],
zoomOut: ['continuous'],
});
});
it('should allow configured PTZ actions to override', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
ptz: {
actions_left: {
action: 'perform-action',
perform_action: 'homeassistant.toggle',
target: {
entity_id: 'switch.camera_move_left',
},
},
},
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
await camera.initialize({
hass: createHASS(),
entityRegistryManager: ptzPopulatedEntityRegistryManager,
stateWatcher: mock<StateWatcher>(),
});
expect(camera.getCapabilities()?.getPTZCapabilities()).toEqual({
left: ['relative'],
right: ['continuous'],
up: ['continuous'],
down: ['continuous'],
zoomIn: ['continuous'],
zoomOut: ['continuous'],
});
});
});
}); });
describe('should get proxy config', () => { describe('should get proxy config', () => {
@@ -213,4 +330,103 @@ describe('ReolinkCamera', () => {
}, },
); );
}); });
describe('should execute PTZ action', () => {
it('should ignore actions without matching button', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
await camera.initialize({
hass: createHASS(),
entityRegistryManager: new EntityRegistryManagerMock([cameraEntity]),
stateWatcher: mock<StateWatcher>(),
});
const executor = mock<ActionsExecutor>();
await camera.executePTZAction(executor, 'left');
await camera.executePTZAction(executor, 'left', { phase: 'start' });
expect(executor.executeActions).not.toBeCalled();
});
it('should ignore actions with configured action', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
ptz: {
actions_left_start: {
action: 'perform-action',
perform_action: 'button.press',
target: {
entity_id: 'button.foo',
},
},
},
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
await camera.initialize({
hass: createHASS(),
entityRegistryManager: new EntityRegistryManagerMock([
cameraEntity,
buttonEntityPTZLeft,
]),
stateWatcher: mock<StateWatcher>(),
});
const executor = mock<ActionsExecutor>();
await camera.executePTZAction(executor, 'left', { phase: 'start' });
expect(executor.executeActions).toBeCalledTimes(1);
expect(executor.executeActions).toHaveBeenLastCalledWith({
actions: {
action: 'perform-action',
perform_action: 'button.press',
target: {
entity_id: 'button.foo',
},
},
});
});
it('should execute action with matching button', async () => {
const config = createCameraConfig({
camera_entity: 'camera.office_reolink',
});
const camera = new ReolinkCamera(config, mock<CameraManagerEngine>());
await camera.initialize({
hass: createHASS(),
entityRegistryManager: ptzPopulatedEntityRegistryManager,
stateWatcher: mock<StateWatcher>(),
});
const executor = mock<ActionsExecutor>();
await camera.executePTZAction(executor, 'left', { phase: 'start' });
expect(executor.executeActions).toHaveBeenLastCalledWith({
actions: [
{
action: 'perform-action',
perform_action: 'button.press',
target: {
entity_id: 'button.office_reolink_ptz_left',
},
},
],
});
await camera.executePTZAction(executor, 'left', { phase: 'stop' });
expect(executor.executeActions).toHaveBeenLastCalledWith({
actions: [
{
action: 'perform-action',
perform_action: 'button.press',
target: {
entity_id: 'button.office_reolink_ptz_stop',
},
},
],
});
});
});
}); });
@@ -29,7 +29,7 @@ import {
BrowseMedia, BrowseMedia,
browseMediaSchema, browseMediaSchema,
} from '../../../src/utils/ha/browse-media/types'; } from '../../../src/utils/ha/browse-media/types';
import { EntityRegistryManager } from '../../../src/utils/ha/registry/entity'; import { EntityRegistryManager } from '../../../src/utils/ha/registry/entity/types';
import { ResolvedMediaCache } from '../../../src/utils/ha/resolved-media'; import { ResolvedMediaCache } from '../../../src/utils/ha/resolved-media';
import { homeAssistantWSRequest } from '../../../src/utils/ha/ws-request'; import { homeAssistantWSRequest } from '../../../src/utils/ha/ws-request';
import { import {
@@ -39,6 +39,7 @@ import {
createRegistryEntity, createRegistryEntity,
createStore, createStore,
} from '../../test-utils'; } from '../../test-utils';
import { EntityRegistryManagerMock } from '../../utils/ha/registry/entity/mock';
vi.mock('../../../src/utils/ha/ws-request'); vi.mock('../../../src/utils/ha/ws-request');
@@ -184,7 +185,7 @@ const createEngine = (options?: {
entityRegistryManager?: EntityRegistryManager; entityRegistryManager?: EntityRegistryManager;
}): ReolinkCameraManagerEngine => { }): ReolinkCameraManagerEngine => {
return new ReolinkCameraManagerEngine( return new ReolinkCameraManagerEngine(
options?.entityRegistryManager ?? mock<EntityRegistryManager>(), options?.entityRegistryManager ?? new EntityRegistryManagerMock(),
mock<StateWatcher>(), mock<StateWatcher>(),
options?.browseMediaManager ?? new BrowseMediaManager(), options?.browseMediaManager ?? new BrowseMediaManager(),
new ResolvedMediaCache(), new ResolvedMediaCache(),
@@ -192,15 +193,15 @@ const createEngine = (options?: {
); );
}; };
const createPopulatedEngine = (): ReolinkCameraManagerEngine => { const cameraEntity = createRegistryEntity({
const entityRegistryManager = mock<EntityRegistryManager>(); entity_id: 'camera.office',
const entity = createRegistryEntity({
unique_id: '85270002TS7D4RUP_0_main', unique_id: '85270002TS7D4RUP_0_main',
platform: 'reolink', platform: 'reolink',
config_entry_id: '01J8XHYTNH77WE3C654K03KX1F', config_entry_id: '01J8XHYTNH77WE3C654K03KX1F',
}); });
vi.mocked(entityRegistryManager.getEntity).mockResolvedValue(entity);
const createPopulatedEngine = (): ReolinkCameraManagerEngine => {
const entityRegistryManager = new EntityRegistryManagerMock([cameraEntity]);
return createEngine({ entityRegistryManager }); return createEngine({ entityRegistryManager });
}; };
@@ -243,14 +244,7 @@ describe('ReolinkCameraManagerEngine', () => {
}); });
it('should create camera', async () => { it('should create camera', async () => {
const entityRegistryManager = mock<EntityRegistryManager>(); const engine = createPopulatedEngine();
const entity = createRegistryEntity({
unique_id: '85270002TS7D4RUP_0_main',
platform: 'reolink',
});
vi.mocked(entityRegistryManager.getEntity).mockResolvedValue(entity);
const engine = createEngine({ entityRegistryManager });
const config = createCameraConfig({ const config = createCameraConfig({
camera_entity: 'camera.office', camera_entity: 'camera.office',
unique_id: 'office', unique_id: 'office',
@@ -589,15 +583,15 @@ describe('ReolinkCameraManagerEngine', () => {
describe('should ignore invalid cameras', () => { describe('should ignore invalid cameras', () => {
it('camera without a config entry id', async () => { it('camera without a config entry id', async () => {
const entityRegistryManager = mock<EntityRegistryManager>();
const entity = createRegistryEntity({ const entity = createRegistryEntity({
entity_id: 'camera.office',
unique_id: '85270002TS7D4RUP_0_main', unique_id: '85270002TS7D4RUP_0_main',
platform: 'reolink', platform: 'reolink',
// Cannot fetch events without a config_entry_id. // Cannot fetch events without a config_entry_id.
config_entry_id: null, config_entry_id: null,
}); });
vi.mocked(entityRegistryManager.getEntity).mockResolvedValue(entity); const entityRegistryManager = new EntityRegistryManagerMock([entity]);
const engine = createEngine({ entityRegistryManager }); const engine = createEngine({ entityRegistryManager });
const store = await createStoreWithReolinkCamera(engine); const store = await createStoreWithReolinkCamera(engine);
@@ -1098,15 +1092,15 @@ describe('ReolinkCameraManagerEngine', () => {
describe('should ignore invalid cameras', () => { describe('should ignore invalid cameras', () => {
it('should get no metdata for camera without a config entry id', async () => { it('should get no metdata for camera without a config entry id', async () => {
const entityRegistryManager = mock<EntityRegistryManager>();
const entity = createRegistryEntity({ const entity = createRegistryEntity({
entity_id: 'camera.office',
unique_id: '85270002TS7D4RUP_0_main', unique_id: '85270002TS7D4RUP_0_main',
platform: 'reolink', platform: 'reolink',
// Cannot fetch events without a config_entry_id. // Cannot fetch events without a config_entry_id.
config_entry_id: null, config_entry_id: null,
}); });
vi.mocked(entityRegistryManager.getEntity).mockResolvedValue(entity); const entityRegistryManager = new EntityRegistryManagerMock([entity]);
const engine = createEngine({ entityRegistryManager }); const engine = createEngine({ entityRegistryManager });
const store = await createStoreWithReolinkCamera(engine); const store = await createStoreWithReolinkCamera(engine);
+1 -1
View File
@@ -6,7 +6,7 @@ import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-fact
import { CameraManagerStore } from '../../src/camera-manager/store.js'; import { CameraManagerStore } from '../../src/camera-manager/store.js';
import { Engine } from '../../src/camera-manager/types.js'; import { Engine } from '../../src/camera-manager/types.js';
import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js'; import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
import { EntityRegistryManager } from '../../src/utils/ha/registry/entity/index.js'; import { EntityRegistryManager } from '../../src/utils/ha/registry/entity/types.js';
import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media.js'; import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media.js';
import { TestViewMedia, createCameraConfig } from '../test-utils.js'; import { TestViewMedia, createCameraConfig } from '../test-utils.js';
@@ -258,7 +258,7 @@ describe('ActionsManager', () => {
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
await manager.handleActionExecutionRequestEvent( await manager.handleActionExecutionRequestEvent(
new CustomEvent('advanced-camera-card:action:execution-request', { new CustomEvent('advanced-camera-card:action:execution-request', {
detail: { action: createLogAction('Hello, world!') }, detail: { actions: createLogAction('Hello, world!') },
}), }),
); );
expect(consoleSpy).toBeCalled(); expect(consoleSpy).toBeCalled();
@@ -271,7 +271,7 @@ describe('ActionsManager', () => {
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
await manager.executeActions(createLogAction('Hello, world!')); await manager.executeActions({ actions: createLogAction('Hello, world!') });
expect(consoleSpy).toBeCalled(); expect(consoleSpy).toBeCalled();
}); });
@@ -280,7 +280,7 @@ describe('ActionsManager', () => {
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
await manager.executeActions(createLogAction('Hello, world!')); await manager.executeActions({ actions: createLogAction('Hello, world!') });
expect(consoleSpy).toBeCalled(); expect(consoleSpy).toBeCalled();
}); });
@@ -303,10 +303,7 @@ describe('ActionsManager', () => {
const config = { entity: 'light.office' }; const config = { entity: 'light.office' };
const triggerData = { view: { from: 'previous-view', to: 'view' } }; const triggerData = { view: { from: 'previous-view', to: 'view' } };
await manager.executeActions(action, { await manager.executeActions({ actions: action, config, triggerData });
config,
triggerData,
});
expect(templateRenderer.renderRecursively).toBeCalledWith(hass, action, { expect(templateRenderer.renderRecursively).toBeCalledWith(hass, action, {
conditionState, conditionState,
@@ -326,7 +323,7 @@ describe('ActionsManager', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
await manager.executeActions({ action: 'none' }); await manager.executeActions({ actions: { action: 'none' } });
expect(handler).toBeCalledWith(expect.objectContaining({ detail: 'success' })); expect(handler).toBeCalledWith(expect.objectContaining({ detail: 'success' }));
}); });
@@ -340,7 +337,9 @@ describe('ActionsManager', () => {
vi.stubGlobal('confirm', vi.fn().mockReturnValue(false)); vi.stubGlobal('confirm', vi.fn().mockReturnValue(false));
await manager.executeActions({ action: 'none', confirmation: true }); await manager.executeActions({
actions: { action: 'none', confirmation: true },
});
expect(handler).toBeCalledWith(expect.objectContaining({ detail: 'warning' })); expect(handler).toBeCalledWith(expect.objectContaining({ detail: 'warning' }));
}); });
@@ -360,7 +359,8 @@ describe('ActionsManager', () => {
const manager = new ActionsManager(api); const manager = new ActionsManager(api);
const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined); const consoleSpy = vi.spyOn(global.console, 'info').mockReturnValue(undefined);
const promise = manager.executeActions([ const promise = manager.executeActions({
actions: [
{ {
action: 'fire-dom-event', action: 'fire-dom-event',
advanced_camera_card_action: 'sleep', advanced_camera_card_action: 'sleep',
@@ -369,7 +369,8 @@ describe('ActionsManager', () => {
}, },
}, },
createLogAction('Hello, world!'), createLogAction('Hello, world!'),
]); ],
});
// Stop inflight actions. // Stop inflight actions.
await manager.uninitialize(); await manager.uninitialize();
@@ -1,5 +1,7 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { Capabilities } from '../../../../src/camera-manager/capabilities';
import { PTZAction } from '../../../../src/card-controller/actions/actions/ptz'; import { PTZAction } from '../../../../src/card-controller/actions/actions/ptz';
import { PTZMovementType } from '../../../../src/types';
import { import {
createCameraConfig, createCameraConfig,
createCameraManager, createCameraManager,
@@ -7,7 +9,6 @@ import {
createStore, createStore,
createView, createView,
} from '../../../test-utils'; } from '../../../test-utils';
import { Capabilities } from '../../../../src/camera-manager/capabilities';
describe('should handle ptz action', () => { describe('should handle ptz action', () => {
it('should execute simple action', async () => { it('should execute simple action', async () => {
@@ -20,7 +21,7 @@ describe('should handle ptz action', () => {
const store = createStore([ const store = createStore([
{ {
cameraID: 'camera.office', cameraID: 'camera.office',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }), capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
}, },
]); ]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
@@ -53,7 +54,7 @@ describe('should handle ptz action', () => {
const store = createStore([ const store = createStore([
{ {
cameraID: 'camera.office', cameraID: 'camera.office',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }), capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
}, },
]); ]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
@@ -93,7 +94,7 @@ describe('should handle ptz action', () => {
}, },
{ {
cameraID: 'camera.office_hd', cameraID: 'camera.office_hd',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }), capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
}, },
]); ]);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
@@ -210,7 +211,7 @@ describe('should handle ptz action', () => {
cameraID: 'camera.office', cameraID: 'camera.office',
capabilities: new Capabilities({ capabilities: new Capabilities({
ptz: { ptz: {
left: ['relative'], left: [PTZMovementType.Relative],
presets: [], presets: [],
}, },
}), }),
@@ -281,7 +282,7 @@ describe('should handle ptz action', () => {
cameraID: 'camera.office', cameraID: 'camera.office',
capabilities: new Capabilities({ capabilities: new Capabilities({
ptz: { ptz: {
left: ['continuous'], left: [PTZMovementType.Continuous],
}, },
}), }),
}, },
@@ -329,7 +330,7 @@ describe('should handle ptz action', () => {
cameraID: 'camera.office', cameraID: 'camera.office',
capabilities: new Capabilities({ capabilities: new Capabilities({
ptz: { ptz: {
left: ['continuous'], left: [PTZMovementType.Continuous],
}, },
}), }),
}, },
@@ -375,7 +376,7 @@ describe('should handle ptz action', () => {
cameraID: 'camera.office', cameraID: 'camera.office',
capabilities: new Capabilities({ capabilities: new Capabilities({
ptz: { ptz: {
left: ['continuous'], left: [PTZMovementType.Continuous],
}, },
}), }),
}, },
@@ -419,7 +420,7 @@ describe('should handle ptz action', () => {
cameraID: 'camera.office', cameraID: 'camera.office',
capabilities: new Capabilities({ capabilities: new Capabilities({
ptz: { ptz: {
left: ['relative'], left: [PTZMovementType.Relative],
}, },
}), }),
}, },
@@ -473,7 +474,7 @@ describe('should handle ptz action', () => {
cameraID: 'camera.office', cameraID: 'camera.office',
capabilities: new Capabilities({ capabilities: new Capabilities({
ptz: { ptz: {
left: ['relative'], left: [PTZMovementType.Relative],
}, },
}), }),
}, },
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { ActionsExecutionRequest } from '../../src/card-controller/actions/types.js';
import { AutomationsManager } from '../../src/card-controller/automations-manager.js'; import { AutomationsManager } from '../../src/card-controller/automations-manager.js';
import { ConditionStateManager } from '../../src/conditions/state-manager.js'; import { ConditionStateManager } from '../../src/conditions/state-manager.js';
import { ActionConfig } from '../../src/config/schema/actions/types.js';
import { createCardAPI } from '../test-utils.js'; import { createCardAPI } from '../test-utils.js';
describe('AutomationsManager', () => { describe('AutomationsManager', () => {
@@ -150,9 +150,7 @@ describe('AutomationsManager', () => {
vi.mocked(api.getActionsManager().executeActions).mockImplementation( vi.mocked(api.getActionsManager().executeActions).mockImplementation(
async ( async (
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
_action: ActionConfig | ActionConfig[], _request: ActionsExecutionRequest,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_options?: unknown,
): Promise<void> => { ): Promise<void> => {
fullscreen = !fullscreen; fullscreen = !fullscreen;
stateManager.setState({ fullscreen: fullscreen }); stateManager.setState({ fullscreen: fullscreen });
+2 -2
View File
@@ -29,7 +29,7 @@ import { ViewManager } from '../../src/card-controller/view/view-manager';
import { ConditionStateManager } from '../../src/conditions/state-manager'; import { ConditionStateManager } from '../../src/conditions/state-manager';
import { AdvancedCameraCardEditor } from '../../src/editor'; import { AdvancedCameraCardEditor } from '../../src/editor';
import { DeviceRegistryManager } from '../../src/utils/ha/registry/device'; import { DeviceRegistryManager } from '../../src/utils/ha/registry/device';
import { EntityRegistryManager } from '../../src/utils/ha/registry/entity'; import { EntityRegistryManagerLive } from '../../src/utils/ha/registry/entity';
import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media'; import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media';
vi.mock('../../src/camera-manager/manager'); vi.mock('../../src/camera-manager/manager');
@@ -160,7 +160,7 @@ describe('CardController', () => {
it('getEntityRegistryManager', () => { it('getEntityRegistryManager', () => {
expect(createController().getEntityRegistryManager()).toBe( expect(createController().getEntityRegistryManager()).toBe(
vi.mocked(EntityRegistryManager).mock.instances[0], vi.mocked(EntityRegistryManagerLive).mock.instances[0],
); );
}); });
@@ -7,7 +7,7 @@ import {
MEDIA_PLAYER_SUPPORT_TURN_OFF, MEDIA_PLAYER_SUPPORT_TURN_OFF,
} from '../../src/const'; } from '../../src/const';
import { HomeAssistant } from '../../src/ha/types.js'; import { HomeAssistant } from '../../src/ha/types.js';
import { EntityRegistryManager } from '../../src/utils/ha/registry/entity/index.js'; import { EntityRegistryManager } from '../../src/utils/ha/registry/entity/types.js';
import { import {
createCameraConfig, createCameraConfig,
createCameraManager, createCameraManager,
@@ -19,6 +19,7 @@ import {
createStore, createStore,
TestViewMedia, TestViewMedia,
} from '../test-utils.js'; } from '../test-utils.js';
import { EntityRegistryManagerMock } from '../utils/ha/registry/entity/mock.js';
const createHASSWithMediaPlayers = (): HomeAssistant => { const createHASSWithMediaPlayers = (): HomeAssistant => {
const attributesSupported = { const attributesSupported = {
@@ -68,13 +69,16 @@ describe('MediaPlayerManager', () => {
describe('should initialize', () => { describe('should initialize', () => {
it('correctly', async () => { it('correctly', async () => {
const entityRegistryManager = mock<EntityRegistryManager>(); const entityRegistryManager = new EntityRegistryManagerMock([
entityRegistryManager.getEntities.mockResolvedValue( createRegistryEntity({
new Map([ entity_id: 'media_player.ok1',
['media_player.ok1', createRegistryEntity({ hidden_by: '' })], hidden_by: '',
['media_player.ok2', createRegistryEntity({ hidden_by: 'user' })], }),
]), createRegistryEntity({
); entity_id: 'media_player.ok2',
hidden_by: 'user',
}),
]);
const api = createCardAPI(); const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue( vi.mocked(api.getHASSManager().getHASS).mockReturnValue(
@@ -150,13 +154,16 @@ describe('MediaPlayerManager', () => {
}); });
it('should reinitialize when there is a config change', async () => { it('should reinitialize when there is a config change', async () => {
const entityRegistryManager = mock<EntityRegistryManager>(); const entityRegistryManager = new EntityRegistryManagerMock([
entityRegistryManager.getEntities.mockResolvedValue( createRegistryEntity({
new Map([ entity_id: 'media_player.ok1',
['media_player.ok1', createRegistryEntity({ hidden_by: '' })], hidden_by: '',
['media_player.ok2', createRegistryEntity({ hidden_by: 'user' })], }),
]), createRegistryEntity({
); entity_id: 'media_player.ok2',
hidden_by: 'user',
}),
]);
const api = createCardAPI(); const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue( vi.mocked(api.getHASSManager().getHASS).mockReturnValue(
@@ -77,13 +77,15 @@ describe('QueryStringManager', () => {
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy(); expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
await manager.executeIfNecessary(); await manager.executeIfNecessary();
expect(api.getActionsManager().executeActions).toBeCalledWith([ expect(api.getActionsManager().executeActions).toBeCalledWith({
actions: [
{ {
action: 'fire-dom-event', action: 'fire-dom-event',
card_id: 'id', card_id: 'id',
advanced_camera_card_action: action, advanced_camera_card_action: action,
}, },
]); ],
});
}); });
}); });
+3 -3
View File
@@ -384,7 +384,7 @@ describe('MenuController', () => {
controller.handleAction(createInteractionActionEvent('tap'), tapActionConfig); controller.handleAction(createInteractionActionEvent('tap'), tapActionConfig);
expect(handler).toBeCalledWith( expect(handler).toBeCalledWith(
expect.objectContaining({ expect.objectContaining({
detail: { action: [action], config: tapActionConfig }, detail: { actions: [action], config: tapActionConfig },
}), }),
); );
expect(controller.isExpanded()).toBeFalsy(); expect(controller.isExpanded()).toBeFalsy();
@@ -402,7 +402,7 @@ describe('MenuController', () => {
); );
expect(handler).toBeCalledWith( expect(handler).toBeCalledWith(
expect.objectContaining({ expect.objectContaining({
detail: { action: [action], config: tapActionConfig }, detail: { actions: [action], config: tapActionConfig },
}), }),
); );
}); });
@@ -418,7 +418,7 @@ describe('MenuController', () => {
expect(handler).toBeCalledWith( expect(handler).toBeCalledWith(
expect.objectContaining({ expect.objectContaining({
detail: { action: [action, action, action], config: tapActionConfigMulti }, detail: { actions: [action, action, action], config: tapActionConfigMulti },
}), }),
); );
}); });
+16 -15
View File
@@ -6,6 +6,7 @@ import {
PTZControlsConfig, PTZControlsConfig,
ptzControlsConfigSchema, ptzControlsConfigSchema,
} from '../../../src/config/schema/common/controls/ptz'; } from '../../../src/config/schema/common/controls/ptz';
import { PTZMovementType } from '../../../src/types';
import { createCameraManager, createCapabilities, createStore } from '../../test-utils'; import { createCameraManager, createCapabilities, createStore } from '../../test-utils';
const createConfig = (config?: Partial<PTZControlsConfig>): PTZControlsConfig => { const createConfig = (config?: Partial<PTZControlsConfig>): PTZControlsConfig => {
@@ -122,14 +123,14 @@ describe('PTZController', () => {
const store = createStore([ const store = createStore([
{ {
cameraID: 'camera.office', cameraID: 'camera.office',
capabilities: new Capabilities({ ptz: { left: ['relative'] } }), capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
}, },
]); ]);
const cameraManager = createCameraManager(store); const cameraManager = createCameraManager(store);
vi.mocked(cameraManager).getCameraCapabilities.mockReturnValue( vi.mocked(cameraManager).getCameraCapabilities.mockReturnValue(
createCapabilities({ createCapabilities({
ptz: { ptz: {
left: ['relative'], left: [PTZMovementType.Relative],
}, },
}), }),
); );
@@ -158,12 +159,12 @@ describe('PTZController', () => {
cameraID: 'camera.office', cameraID: 'camera.office',
capabilities: new Capabilities({ capabilities: new Capabilities({
ptz: { ptz: {
left: ['relative'], left: [PTZMovementType.Relative],
right: ['relative'], right: [PTZMovementType.Relative],
up: ['relative'], up: [PTZMovementType.Relative],
down: ['relative'], down: [PTZMovementType.Relative],
zoomIn: ['relative'], zoomIn: [PTZMovementType.Relative],
zoomOut: ['relative'], zoomOut: [PTZMovementType.Relative],
}, },
}), }),
}, },
@@ -197,12 +198,12 @@ describe('PTZController', () => {
cameraID: 'camera.office', cameraID: 'camera.office',
capabilities: new Capabilities({ capabilities: new Capabilities({
ptz: { ptz: {
left: ['relative'], left: [PTZMovementType.Relative],
right: ['relative'], right: [PTZMovementType.Relative],
up: ['relative'], up: [PTZMovementType.Relative],
down: ['relative'], down: [PTZMovementType.Relative],
zoomIn: ['relative'], zoomIn: [PTZMovementType.Relative],
zoomOut: ['relative'], zoomOut: [PTZMovementType.Relative],
presets: ['door', 'window'], presets: ['door', 'window'],
}, },
}), }),
@@ -387,7 +388,7 @@ describe('PTZController', () => {
expect(handler).toBeCalledWith( expect(handler).toBeCalledWith(
expect.objectContaining({ expect.objectContaining({
detail: { detail: {
action: action, actions: action,
config: config, config: config,
}, },
}), }),
@@ -325,7 +325,7 @@ describe('StatusBarController', () => {
expect(handler).toBeCalledWith( expect(handler).toBeCalledWith(
expect.objectContaining({ expect.objectContaining({
detail: { action: [action], config: tapActionConfig }, detail: { actions: [action], config: tapActionConfig },
}), }),
); );
}); });
+1 -2
View File
@@ -54,8 +54,7 @@ import { CurrentUser, HomeAssistant } from '../src/ha/types';
import { CapabilitiesRaw, Interaction, MediaLoadedInfo } from '../src/types'; import { CapabilitiesRaw, Interaction, MediaLoadedInfo } from '../src/types';
import { HassStateDifference } from '../src/utils/ha'; import { HassStateDifference } from '../src/utils/ha';
import { Device } from '../src/utils/ha/registry/device/types'; import { Device } from '../src/utils/ha/registry/device/types';
import { EntityRegistryManager } from '../src/utils/ha/registry/entity'; import { Entity, EntityRegistryManager } from '../src/utils/ha/registry/entity/types';
import { Entity } from '../src/utils/ha/registry/entity/types';
import { ViewMedia, ViewMediaType } from '../src/view/media'; import { ViewMedia, ViewMediaType } from '../src/view/media';
import { MediaQueriesResults } from '../src/view/media-queries-results'; import { MediaQueriesResults } from '../src/view/media-queries-results';
import { View, ViewParameters } from '../src/view/view'; import { View, ViewParameters } from '../src/view/view';
+8 -8
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { import {
createEntityRegistryCache, createEntityRegistryCache,
EntityRegistryManager, EntityRegistryManagerLive,
} from '../../../../../src/utils/ha/registry/entity'; } from '../../../../../src/utils/ha/registry/entity';
import { homeAssistantWSRequest } from '../../../../../src/utils/ha/ws-request'; import { homeAssistantWSRequest } from '../../../../../src/utils/ha/ws-request';
import { createHASS, createRegistryEntity } from '../../../../test-utils.js'; import { createHASS, createRegistryEntity } from '../../../../test-utils.js';
@@ -21,7 +21,7 @@ describe('EntityRegistryManager', () => {
cache.add(testEntity); cache.add(testEntity);
const manager = new EntityRegistryManager(cache); const manager = new EntityRegistryManagerLive(cache);
expect(await manager.getEntity(createHASS(), 'test')).toEqual(testEntity); expect(await manager.getEntity(createHASS(), 'test')).toEqual(testEntity);
expect(homeAssistantWSRequest).not.toHaveBeenCalled(); expect(homeAssistantWSRequest).not.toHaveBeenCalled();
@@ -30,7 +30,7 @@ describe('EntityRegistryManager', () => {
it('should fetch and cache when not cached', async () => { it('should fetch and cache when not cached', async () => {
const testEntity = createRegistryEntity({ entity_id: 'test' }); const testEntity = createRegistryEntity({ entity_id: 'test' });
const manager = new EntityRegistryManager(createEntityRegistryCache()); const manager = new EntityRegistryManagerLive(createEntityRegistryCache());
vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(testEntity); vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(testEntity);
expect(await manager.getEntity(createHASS(), 'test')).toEqual(testEntity); expect(await manager.getEntity(createHASS(), 'test')).toEqual(testEntity);
@@ -43,7 +43,7 @@ describe('EntityRegistryManager', () => {
it('should return null when entity does not exist', async () => { it('should return null when entity does not exist', async () => {
vi.mocked(homeAssistantWSRequest).mockRejectedValueOnce(new Error('Not found')); vi.mocked(homeAssistantWSRequest).mockRejectedValueOnce(new Error('Not found'));
const manager = new EntityRegistryManager(createEntityRegistryCache()); const manager = new EntityRegistryManagerLive(createEntityRegistryCache());
expect(await manager.getEntity(createHASS(), 'missing')).toBeNull(); expect(await manager.getEntity(createHASS(), 'missing')).toBeNull();
vi.mocked(expect(console.warn)).toBeCalledWith('Not found'); vi.mocked(expect(console.warn)).toBeCalledWith('Not found');
@@ -57,7 +57,7 @@ describe('EntityRegistryManager', () => {
const cache = createEntityRegistryCache(); const cache = createEntityRegistryCache();
cache.add(cachedEntity); cache.add(cachedEntity);
const manager = new EntityRegistryManager(cache); const manager = new EntityRegistryManagerLive(cache);
vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(notCachedEntity); vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(notCachedEntity);
vi.mocked(homeAssistantWSRequest).mockRejectedValueOnce(new Error('Not found')); vi.mocked(homeAssistantWSRequest).mockRejectedValueOnce(new Error('Not found'));
@@ -79,7 +79,7 @@ describe('EntityRegistryManager', () => {
const entity = createRegistryEntity({ entity_id: 'cached' }); const entity = createRegistryEntity({ entity_id: 'cached' });
vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce([entity]); vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce([entity]);
const manager = new EntityRegistryManager(createEntityRegistryCache()); const manager = new EntityRegistryManagerLive(createEntityRegistryCache());
await manager.fetchEntityList(hass); await manager.fetchEntityList(hass);
@@ -103,7 +103,7 @@ describe('EntityRegistryManager', () => {
const hass = createHASS(); const hass = createHASS();
vi.mocked(homeAssistantWSRequest).mockRejectedValueOnce(new Error('Fetch error')); vi.mocked(homeAssistantWSRequest).mockRejectedValueOnce(new Error('Fetch error'));
const manager = new EntityRegistryManager(createEntityRegistryCache()); const manager = new EntityRegistryManagerLive(createEntityRegistryCache());
await manager.fetchEntityList(hass); await manager.fetchEntityList(hass);
@@ -121,7 +121,7 @@ describe('EntityRegistryManager', () => {
notMatchingEntity, notMatchingEntity,
]); ]);
const manager = new EntityRegistryManager(createEntityRegistryCache()); const manager = new EntityRegistryManagerLive(createEntityRegistryCache());
expect( expect(
await manager.getMatchingEntities( await manager.getMatchingEntities(
hass, hass,
+47
View File
@@ -0,0 +1,47 @@
import { HomeAssistant } from '../../../../../src/ha/types';
import { RegistryCache } from '../../../../../src/utils/ha/registry/cache';
import {
Entity,
EntityRegistryManager,
} from '../../../../../src/utils/ha/registry/entity/types';
export class EntityRegistryManagerMock implements EntityRegistryManager {
protected _cache: RegistryCache<Entity>;
protected _fetchedEntityList = false;
constructor(data?: Entity[]) {
this._cache = new RegistryCache<Entity>((ent) => ent.entity_id);
this._cache.add(data ?? []);
}
public async getEntity(
_hass: HomeAssistant,
entityID: string,
): Promise<Entity | null> {
return this._cache.get(entityID);
}
public async getMatchingEntities(
_hass: HomeAssistant,
func: (arg: Entity) => boolean,
): Promise<Entity[]> {
return this._cache.getMatches(func);
}
public async getEntities(
hass: HomeAssistant,
entityIDs: string[],
): Promise<Map<string, Entity>> {
const output: Map<string, Entity> = new Map();
for (const entityID of entityIDs) {
const entityData = await this.getEntity(hass, entityID);
if (entityData) {
output.set(entityID, entityData);
}
}
return output;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async fetchEntityList(_hass: HomeAssistant): Promise<void> {}
}