Merge pull request #1279 from dermotduffy/card.ts-refactor

Refactor `card.ts` and `types.ts` and add tests
This commit is contained in:
Dermot Duffy
2023-10-03 20:47:45 -07:00
committed by GitHub
130 changed files with 10937 additions and 4846 deletions
+23
View File
@@ -180,6 +180,29 @@ export const actionHandler = directive(
}, },
); );
export interface ActionEventTarget extends EventTarget {
addEventListener(
event: '@action',
listener: (this: ActionEventTarget, ev: CustomEvent<ActionHandlerDetail>) => void,
options?: AddEventListenerOptions | boolean,
): void;
addEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
options?: AddEventListenerOptions | boolean,
): void;
removeEventListener(
event: '@action',
listener: (this: ActionEventTarget, ev: CustomEvent<ActionHandlerDetail>) => void,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void;
}
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
'action-handler-frigate-card': ActionHandler; 'action-handler-frigate-card': ActionHandler;
-50
View File
@@ -1,50 +0,0 @@
import { HomeAssistant } from 'custom-card-helpers';
import { AutomationActions, FrigateCardError } from './types.js';
import { ConditionController } from './conditions.js';
import { Automation, Automations } from './types.js';
import { frigateCardHandleAction } from './utils/action.js';
import { localize } from './localize/localize.js';
const MAX_NESTED_AUTOMATION_EXECUTIONS = 10;
export class AutomationsControllerError extends FrigateCardError {}
export class AutomationsController {
protected _automations: Automations;
protected _priorEvaluations: Map<Automation, boolean> = new Map();
// A counter to avoid infinite loops, increases every time actions are run,
// decreases every time actions are complete.
protected _nestedAutomationExecutions = 0;
constructor(automations: Automations) {
this._automations = automations;
}
public execute(
element: HTMLElement,
hass: HomeAssistant,
conditionController: ConditionController,
): void {
const actionsToRun: AutomationActions[] = [];
for (const automation of this._automations ?? []) {
const shouldExecute = conditionController.evaluateCondition(automation.conditions);
const actions = shouldExecute ? automation.actions : automation.actions_not;
const priorEvaluation = this._priorEvaluations.get(automation);
this._priorEvaluations.set(automation, shouldExecute);
if (shouldExecute !== priorEvaluation && actions) {
actionsToRun.push(actions);
}
}
++this._nestedAutomationExecutions;
if (this._nestedAutomationExecutions > MAX_NESTED_AUTOMATION_EXECUTIONS) {
throw new AutomationsControllerError(localize('error.too_many_automations'));
}
actionsToRun.forEach((actions) => {
frigateCardHandleAction(element, hass, {}, actions);
});
--this._nestedAutomationExecutions;
}
}
@@ -1,22 +1,8 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig, ExtendedHomeAssistant } from '../../types'; import { CameraConfig } from '../../config/types';
import { ViewMedia } from '../../view/media';
import {
CameraManagerMediaCapabilities,
DataQuery,
EventQuery,
PartialEventQuery,
CameraConfigs,
CameraManagerCameraCapabilities,
QueryType,
CameraEndpoint,
} from '../types';
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
import { CameraManagerEngine } from '../engine';
import { GenericCameraManagerEngine } from '../generic/engine-generic';
import { CameraInitializationError } from '../error';
import { localize } from '../../localize/localize'; import { localize } from '../../localize/localize';
import { Entity } from '../../utils/ha/entity-registry/types'; import { ExtendedHomeAssistant } from '../../types';
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 { import {
BROWSE_MEDIA_CACHE_SECONDS, BROWSE_MEDIA_CACHE_SECONDS,
@@ -24,12 +10,27 @@ import {
MEDIA_CLASS_VIDEO, MEDIA_CLASS_VIDEO,
RichBrowseMedia, RichBrowseMedia,
} from '../../utils/ha/browse-media/types'; } from '../../utils/ha/browse-media/types';
import { BrowseMediaMetadata } from './types'; import { EntityRegistryManager } from '../../utils/ha/entity-registry';
import { rangesOverlap } from '../range'; import { Entity } from '../../utils/ha/entity-registry/types';
import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media'; import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media';
import { canonicalizeHAURL } from '../../utils/ha'; import { ViewMedia } from '../../view/media';
import { RequestCache } from '../cache'; import { RequestCache } from '../cache';
import { CameraManagerEngine } from '../engine';
import { CameraInitializationError } from '../error';
import { GenericCameraManagerEngine } from '../generic/engine-generic';
import { rangesOverlap } from '../range';
import {
CameraConfigs,
CameraEndpoint,
CameraManagerCameraCapabilities,
CameraManagerMediaCapabilities,
DataQuery,
EventQuery,
PartialEventQuery,
QueryType,
} from '../types';
import { BrowseMediaViewMediaFactory } from './media'; import { BrowseMediaViewMediaFactory } from './media';
import { BrowseMediaMetadata } from './types';
/** /**
* A utility method to determine if a browse media object matches against a * A utility method to determine if a browse media object matches against a
+3 -7
View File
@@ -1,6 +1,6 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig } from '../config/types';
import { localize } from '../localize/localize'; import { localize } from '../localize/localize';
import { CameraConfig, CardWideConfig } from '../types';
import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager'; import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager';
import { BrowseMedia } from '../utils/ha/browse-media/types'; import { BrowseMedia } from '../utils/ha/browse-media/types';
import { EntityRegistryManager } from '../utils/ha/entity-registry'; import { EntityRegistryManager } from '../utils/ha/entity-registry';
@@ -15,20 +15,17 @@ import { getCameraEntityFromConfig } from './util';
export class CameraManagerEngineFactory { export class CameraManagerEngineFactory {
protected _entityRegistryManager: EntityRegistryManager; protected _entityRegistryManager: EntityRegistryManager;
protected _resolvedMediaCache: ResolvedMediaCache; protected _resolvedMediaCache: ResolvedMediaCache;
protected _cardWideConfig: CardWideConfig;
constructor( constructor(
entityRegistryManager: EntityRegistryManager, entityRegistryManager: EntityRegistryManager,
resolvedMediaCache: ResolvedMediaCache, resolvedMediaCache: ResolvedMediaCache,
cardWideConfig: CardWideConfig,
) { ) {
this._entityRegistryManager = entityRegistryManager; this._entityRegistryManager = entityRegistryManager;
this._cardWideConfig = cardWideConfig;
this._resolvedMediaCache = resolvedMediaCache; this._resolvedMediaCache = resolvedMediaCache;
} }
public async createEngine(engine: Engine): Promise<CameraManagerEngine | null> { public async createEngine(engine: Engine): Promise<CameraManagerEngine> {
let cameraManagerEngine: CameraManagerEngine | null = null; let cameraManagerEngine: CameraManagerEngine;
switch (engine) { switch (engine) {
case Engine.Generic: case Engine.Generic:
const { GenericCameraManagerEngine } = await import('./generic/engine-generic'); const { GenericCameraManagerEngine } = await import('./generic/engine-generic');
@@ -37,7 +34,6 @@ export class CameraManagerEngineFactory {
case Engine.Frigate: case Engine.Frigate:
const { FrigateCameraManagerEngine } = await import('./frigate/engine-frigate'); const { FrigateCameraManagerEngine } = await import('./frigate/engine-frigate');
cameraManagerEngine = new FrigateCameraManagerEngine( cameraManagerEngine = new FrigateCameraManagerEngine(
this._cardWideConfig,
new RecordingSegmentsCache(), new RecordingSegmentsCache(),
new RequestCache(), new RequestCache(),
); );
+13 -12
View File
@@ -1,11 +1,23 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig, ExtendedHomeAssistant } from '../types'; import { CameraConfig } from '../config/types';
import { ExtendedHomeAssistant } from '../types';
import { EntityRegistryManager } from '../utils/ha/entity-registry'; import { EntityRegistryManager } from '../utils/ha/entity-registry';
import { ViewMedia } from '../view/media'; import { ViewMedia } from '../view/media';
import { import {
CameraConfigs,
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraManagerCameraCapabilities,
CameraManagerCameraMetadata,
CameraManagerMediaCapabilities,
DataQuery, DataQuery,
Engine,
EngineOptions,
EventQuery, EventQuery,
EventQueryResultsMap, EventQueryResultsMap,
MediaMetadataQuery,
MediaMetadataQueryResultsMap,
PartialEventQuery, PartialEventQuery,
PartialRecordingQuery, PartialRecordingQuery,
PartialRecordingSegmentsQuery, PartialRecordingSegmentsQuery,
@@ -14,17 +26,6 @@ import {
RecordingQueryResultsMap, RecordingQueryResultsMap,
RecordingSegmentsQuery, RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap, RecordingSegmentsQueryResultsMap,
CameraManagerCameraCapabilities,
CameraManagerMediaCapabilities,
CameraManagerCameraMetadata,
CameraEndpointsContext,
CameraConfigs,
Engine,
CameraEndpoints,
MediaMetadataQuery,
MediaMetadataQueryResultsMap,
EngineOptions,
CameraEndpoint,
} from './types'; } from './types';
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
+45 -61
View File
@@ -1,24 +1,53 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import add from 'date-fns/add'; import add from 'date-fns/add';
import endOfHour from 'date-fns/endOfHour'; import endOfHour from 'date-fns/endOfHour';
import format from 'date-fns/format';
import fromUnixTime from 'date-fns/fromUnixTime';
import startOfHour from 'date-fns/startOfHour'; import startOfHour from 'date-fns/startOfHour';
import { CameraConfig, CardWideConfig, ExtendedHomeAssistant } from '../../types'; import isEqual from 'lodash-es/isEqual';
import orderBy from 'lodash-es/orderBy';
import throttle from 'lodash-es/throttle';
import uniq from 'lodash-es/uniq';
import uniqWith from 'lodash-es/uniqWith';
import { CameraConfig } from '../../config/types';
import { localize } from '../../localize/localize';
import { ExtendedHomeAssistant } from '../../types';
import {
allPromises,
formatDate,
prettifyTitle,
runWhenIdleIfSupported,
} from '../../utils/basic';
import { getEntityTitle } from '../../utils/ha';
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
import { Entity } from '../../utils/ha/entity-registry/types';
import { ViewMedia } from '../../view/media'; import { ViewMedia } from '../../view/media';
import { ViewMediaClassifier } from '../../view/media-classifier';
import { RecordingSegmentsCache, RequestCache } from '../cache'; import { RecordingSegmentsCache, RequestCache } from '../cache';
import { import {
CameraManagerEngine,
CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
CameraManagerEngine,
} from '../engine'; } from '../engine';
import { CameraInitializationError } from '../error';
import { GenericCameraManagerEngine } from '../generic/engine-generic';
import { DateRange } from '../range'; import { DateRange } from '../range';
import { import {
CameraManagerCameraMetadata, CameraConfigs,
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraManagerCameraCapabilities, CameraManagerCameraCapabilities,
CameraManagerCameraMetadata,
CameraManagerMediaCapabilities, CameraManagerMediaCapabilities,
DataQuery, DataQuery,
Engine, Engine,
EngineOptions,
EventQuery, EventQuery,
EventQueryResults, EventQueryResults,
EventQueryResultsMap, EventQueryResultsMap,
MediaMetadataQuery,
MediaMetadataQueryResults,
MediaMetadataQueryResultsMap,
PartialEventQuery, PartialEventQuery,
PartialRecordingQuery, PartialRecordingQuery,
PartialRecordingSegmentsQuery, PartialRecordingSegmentsQuery,
@@ -32,56 +61,26 @@ import {
RecordingSegment, RecordingSegment,
RecordingSegmentsQuery, RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap, RecordingSegmentsQueryResultsMap,
CameraEndpointsContext,
CameraConfigs,
CameraEndpoints,
CameraEndpoint,
MediaMetadataQuery,
MediaMetadataQueryResults,
MediaMetadataQueryResultsMap,
EngineOptions,
} from '../types'; } from '../types';
import { getCameraEntityFromConfig } from '../util';
import frigateLogo from './assets/frigate-logo-dark.svg';
import { FrigateViewMediaFactory } from './media';
import { FrigateViewMediaClassifier } from './media-classifier';
import { import {
FrigateEventQueryResults,
FrigateRecordingQueryResults,
FrigateRecordingSegmentsQueryResults,
FrigateRecording,
} from './types';
import {
getEvents,
getEventSummary,
getRecordingSegments,
getRecordingsSummary,
NativeFrigateEventQuery, NativeFrigateEventQuery,
NativeFrigateRecordingSegmentsQuery, NativeFrigateRecordingSegmentsQuery,
getEventSummary,
getEvents,
getRecordingSegments,
getRecordingsSummary,
retainEvent, retainEvent,
} from './requests'; } from './requests';
import isEqual from 'lodash-es/isEqual';
import orderBy from 'lodash-es/orderBy';
import throttle from 'lodash-es/throttle';
import uniqWith from 'lodash-es/uniqWith';
import { import {
allPromises, FrigateEventQueryResults,
formatDate, FrigateRecording,
prettifyTitle, FrigateRecordingQueryResults,
runWhenIdleIfSupported, FrigateRecordingSegmentsQueryResults,
} from '../../utils/basic'; } from './types';
import fromUnixTime from 'date-fns/fromUnixTime';
import sum from 'lodash-es/sum';
import { FrigateViewMediaClassifier } from './media-classifier';
import { ViewMediaClassifier } from '../../view/media-classifier';
import { FrigateViewMediaFactory } from './media';
import { log } from '../../utils/debug';
import { getEntityTitle } from '../../utils/ha';
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
import { Entity } from '../../utils/ha/entity-registry/types';
import { CameraInitializationError } from '../error';
import { localize } from '../../localize/localize';
import uniq from 'lodash-es/uniq';
import format from 'date-fns/format';
import { GenericCameraManagerEngine } from '../generic/engine-generic';
import frigateLogo from './assets/frigate-logo-dark.svg';
import { getCameraEntityFromConfig } from '../util';
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60; const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
@@ -120,7 +119,6 @@ export class FrigateCameraManagerEngine
{ {
protected _recordingSegmentsCache: RecordingSegmentsCache; protected _recordingSegmentsCache: RecordingSegmentsCache;
protected _requestCache: RequestCache; protected _requestCache: RequestCache;
protected _cardWideConfig: CardWideConfig;
// Garbage collect segments at most once an hour. // Garbage collect segments at most once an hour.
protected _throttledSegmentGarbageCollector = throttle( protected _throttledSegmentGarbageCollector = throttle(
@@ -130,12 +128,10 @@ export class FrigateCameraManagerEngine
); );
constructor( constructor(
cardWideConfig: CardWideConfig,
recordingSegmentsCache: RecordingSegmentsCache, recordingSegmentsCache: RecordingSegmentsCache,
requestCache: RequestCache, requestCache: RequestCache,
) { ) {
super(); super();
this._cardWideConfig = cardWideConfig;
this._recordingSegmentsCache = recordingSegmentsCache; this._recordingSegmentsCache = recordingSegmentsCache;
this._requestCache = requestCache; this._requestCache = requestCache;
} }
@@ -989,12 +985,6 @@ export class FrigateCameraManagerEngine
type: QueryType.Recording, type: QueryType.Recording,
}; };
const countSegments = () =>
sum(
cameraIDs.map((cameraID) => this._recordingSegmentsCache.getSize(cameraID) ?? 0),
);
const segmentsStart = countSegments();
// Performance: _recordingSegments is potentially very large (e.g. 10K - 1M // Performance: _recordingSegments is potentially very large (e.g. 10K - 1M
// items) and each item must be examined, so care required here to stick to // items) and each item must be examined, so care required here to stick to
// nothing worse than O(n) performance. // nothing worse than O(n) performance.
@@ -1029,12 +1019,6 @@ export class FrigateCameraManagerEngine
}, },
); );
} }
log(
this._cardWideConfig,
'Frigate Card recording segment garbage collection: ' +
`Released ${segmentsStart - countSegments()} segment(s)`,
);
} }
/** /**
+3 -3
View File
@@ -1,12 +1,12 @@
import fromUnixTime from 'date-fns/fromUnixTime'; import fromUnixTime from 'date-fns/fromUnixTime';
import isEqual from 'lodash-es/isEqual'; import isEqual from 'lodash-es/isEqual';
import { CameraConfig } from '../../types'; import { CameraConfig } from '../../config/types';
import { import {
ViewMedia,
EventViewMedia, EventViewMedia,
RecordingViewMedia, RecordingViewMedia,
ViewMediaType,
VideoContentType, VideoContentType,
ViewMedia,
ViewMediaType,
} from '../../view/media'; } from '../../view/media';
import { FrigateEvent, FrigateRecording } from './types'; import { FrigateEvent, FrigateRecording } from './types';
import { import {
+2 -1
View File
@@ -1,5 +1,6 @@
import utcToZonedTime from 'date-fns-tz/utcToZonedTime'; import utcToZonedTime from 'date-fns-tz/utcToZonedTime';
import { CameraConfig, ClipsOrSnapshots } from '../../types'; import { CameraConfig } from '../../config/types';
import { ClipsOrSnapshots } from '../../types';
import { formatDateAndTime, prettifyTitle } from '../../utils/basic'; import { formatDateAndTime, prettifyTitle } from '../../utils/basic';
import { FrigateEvent, FrigateRecording } from './types'; import { FrigateEvent, FrigateRecording } from './types';
+16 -15
View File
@@ -1,35 +1,36 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig, ExtendedHomeAssistant } from '../../types'; import { CameraConfig } from '../../config/types';
import { ExtendedHomeAssistant } from '../../types';
import { getEntityIcon, getEntityTitle } from '../../utils/ha';
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
import { ViewMedia } from '../../view/media'; import { ViewMedia } from '../../view/media';
import { CameraManagerEngine } from '../engine';
import { import {
CameraConfigs,
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraManagerCameraCapabilities,
CameraManagerCameraMetadata, CameraManagerCameraMetadata,
CameraManagerMediaCapabilities, CameraManagerMediaCapabilities,
DataQuery, DataQuery,
Engine,
EngineOptions,
EventQuery, EventQuery,
EventQueryResultsMap, EventQueryResultsMap,
MediaMetadataQuery,
MediaMetadataQueryResultsMap,
PartialEventQuery, PartialEventQuery,
PartialRecordingQuery, PartialRecordingQuery,
PartialRecordingSegmentsQuery, PartialRecordingSegmentsQuery,
QueryReturnType,
RecordingQuery,
RecordingQueryResultsMap, RecordingQueryResultsMap,
RecordingSegmentsQuery, RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap, RecordingSegmentsQueryResultsMap,
CameraEndpointsContext,
CameraConfigs,
RecordingQuery,
QueryReturnType,
CameraManagerCameraCapabilities,
Engine,
CameraEndpoints,
MediaMetadataQuery,
MediaMetadataQueryResultsMap,
EngineOptions,
CameraEndpoint,
} from '../types'; } from '../types';
import { getEntityIcon, getEntityTitle } from '../../utils/ha';
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
import { CameraManagerEngine } from '../engine';
export class GenericCameraManagerEngine implements CameraManagerEngine { export class GenericCameraManagerEngine implements CameraManagerEngine {
public getEngineType(): Engine { public getEngineType(): Engine {
+119 -90
View File
@@ -1,22 +1,38 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { import add from 'date-fns/add';
CameraConfig, import cloneDeep from 'lodash-es/cloneDeep';
CamerasConfig, import merge from 'lodash-es/merge.js';
CardWideConfig, import sum from 'lodash-es/sum';
ExtendedHomeAssistant, import { CameraConfig, CamerasConfig } from '../config/types.js';
} from '../types.js'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
import { localize } from '../localize/localize.js';
import { allPromises, arrayify, setify } from '../utils/basic.js'; import { allPromises, arrayify, setify } from '../utils/basic.js';
import { getCameraID } from '../utils/camera.js';
import { CardCameraAPI } from '../card-controller/types.js';
import { log } from '../utils/debug.js';
import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js';
import { ViewMedia } from '../view/media.js';
import { CameraManagerEngineFactory } from './engine-factory.js';
import { CameraManagerEngine } from './engine.js';
import { CameraInitializationError } from './error.js';
import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js';
import { import {
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraManagerCameraCapabilities, CameraManagerCameraCapabilities,
CameraManagerCameraMetadata, CameraManagerCameraMetadata,
CameraManagerCapabilities, CameraManagerCapabilities,
CameraManagerMediaCapabilities, CameraManagerMediaCapabilities,
CameraEndpointsContext,
DataQuery, DataQuery,
Engine,
EngineOptions,
EventQuery, EventQuery,
EventQueryResults, EventQueryResults,
EventQueryResultsMap, EventQueryResultsMap,
MediaMetadata, MediaMetadata,
MediaMetadataQuery,
MediaMetadataQueryResults,
MediaQuery, MediaQuery,
PartialDataQuery, PartialDataQuery,
PartialEventQuery, PartialEventQuery,
@@ -34,26 +50,7 @@ import {
RecordingSegmentsQueryResults, RecordingSegmentsQueryResults,
RecordingSegmentsQueryResultsMap, RecordingSegmentsQueryResultsMap,
ResultsMap, ResultsMap,
CameraEndpoints,
Engine,
MediaMetadataQuery,
MediaMetadataQueryResults,
EngineOptions,
CameraEndpoint,
} from './types.js'; } from './types.js';
import { CameraManagerEngineFactory } from './engine-factory.js';
import { ViewMedia } from '../view/media.js';
import { CameraManagerEngine } from './engine.js';
import sum from 'lodash-es/sum';
import add from 'date-fns/add';
import { log } from '../utils/debug.js';
import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js';
import { getCameraID } from '../utils/camera.js';
import { localize } from '../localize/localize.js';
import { CameraInitializationError } from './error.js';
import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js';
import cloneDeep from 'lodash-es/cloneDeep';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
import { sortMedia } from './util.js'; import { sortMedia } from './util.js';
class QueryClassifier { class QueryClassifier {
@@ -112,25 +109,53 @@ interface InitializedCamera {
} }
export class CameraManager { export class CameraManager {
protected _api: CardCameraAPI;
protected _engineFactory: CameraManagerEngineFactory; protected _engineFactory: CameraManagerEngineFactory;
protected _cardWideConfig?: CardWideConfig; protected _store = new CameraManagerStore();
protected _store: CameraManagerStore;
constructor( constructor(api: CardCameraAPI) {
engineFactory: CameraManagerEngineFactory, this._api = api;
cardWideConfig?: CardWideConfig, this._engineFactory = new CameraManagerEngineFactory(
) { this._api.getEntityRegistryManager(),
this._engineFactory = engineFactory; this._api.getResolvedMediaCache(),
this._cardWideConfig = cardWideConfig; );
this._store = new CameraManagerStore(); }
public async initializeCamerasFromConfig(): Promise<void> {
const config = this._api.getConfigManager().getConfig();
const hass = this._api.getHASSManager().getHASS();
if (!config || !hass) {
return;
}
this._store.reset();
// For each camera merge the config (which has no defaults) into the camera
// global config (which does have defaults). The merging must happen in this
// order, to ensure that the defaults in the cameras global config do not
// override the values specified in the per-camera config.
const cameras = config.cameras.map((camera) =>
merge(cloneDeep(config?.cameras_global), camera),
);
try {
await this._initializeCameras(cameras);
} catch (e: unknown) {
this._api.getMessageManager().setErrorIfHigherPriority(e);
}
} }
protected async _getEnginesForCameras( protected async _getEnginesForCameras(
hass: HomeAssistant,
camerasConfig: CamerasConfig, camerasConfig: CamerasConfig,
): Promise<Map<CameraConfig, CameraManagerEngine>> { ): Promise<Map<CameraConfig, CameraManagerEngine>> {
const output: Map<CameraConfig, CameraManagerEngine> = new Map(); const output: Map<CameraConfig, CameraManagerEngine> = new Map();
const engines: Map<Engine, CameraManagerEngine> = new Map(); const engines: Map<Engine, CameraManagerEngine> = new Map();
const hass = this._api.getHASSManager().getHASS();
if (!hass) {
return output;
}
const getEngineTypes = async (configs: CameraConfig[]) => { const getEngineTypes = async (configs: CameraConfig[]) => {
return await allPromises(configs, (config) => return await allPromises(configs, (config) =>
@@ -142,7 +167,7 @@ export class CameraManager {
for (const [index, cameraConfig] of camerasConfig.entries()) { for (const [index, cameraConfig] of camerasConfig.entries()) {
const engineType = engineTypes[index]; const engineType = engineTypes[index];
const engine = engineType const engine = engineType
? engines.get(engineType) ?? await this._engineFactory.createEngine(engineType) ? engines.get(engineType) ?? (await this._engineFactory.createEngine(engineType))
: null; : null;
if (!engine || !engineType) { if (!engine || !engineType) {
throw new CameraInitializationError( throw new CameraInitializationError(
@@ -177,12 +202,13 @@ export class CameraManager {
}; };
} }
public async initializeCameras( protected async _initializeCameras(camerasConfig: CamerasConfig): Promise<void> {
hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager,
camerasConfig: CamerasConfig,
): Promise<void> {
const initializationStartTime = new Date(); const initializationStartTime = new Date();
const hass = this._api.getHASSManager().getHASS();
if (!hass) {
return;
}
const hasAutoTriggers = (config: CameraConfig): boolean => { const hasAutoTriggers = (config: CameraConfig): boolean => {
return config.triggers.motion || config.triggers.occupancy; return config.triggers.motion || config.triggers.occupancy;
@@ -195,18 +221,23 @@ export class CameraManager {
// ... then we need to populate the entity cache by fetching all entities // ... then we need to populate the entity cache by fetching all entities
// from Home Assistant. Do this once upfront, to avoid each camera doing // from Home Assistant. Do this once upfront, to avoid each camera doing
// it. // it.
await entityRegistryManager.fetchEntityList(hass); await this._api.getEntityRegistryManager().fetchEntityList(hass);
} }
// Engines are created sequentially, to avoid duplicate creation of the same // Engines are created sequentially, to avoid duplicate creation of the same
// engine. See: https://github.com/dermotduffy/frigate-hass-card/issues/941 // engine. See: https://github.com/dermotduffy/frigate-hass-card/issues/941
const engineByConfig = await this._getEnginesForCameras(hass, camerasConfig); const engineByConfig = await this._getEnginesForCameras(camerasConfig);
// Configuration is initialized in parallel. // Configuration is initialized in parallel.
const results = await allPromises( const results = await allPromises(
engineByConfig.entries(), engineByConfig.entries(),
async ([cameraConfig, engine]) => async ([cameraConfig, engine]) =>
await this._initializeCamera(hass, engine, entityRegistryManager, cameraConfig), await this._initializeCamera(
hass,
engine,
this._api.getEntityRegistryManager(),
cameraConfig,
),
); );
// Do the additions based off the result-order, to ensure the map order is // Do the additions based off the result-order, to ensure the map order is
@@ -236,7 +267,7 @@ export class CameraManager {
} }
log( log(
this._cardWideConfig, this._api.getConfigManager().getCardWideConfig(),
'Frigate Card CameraManager initialized (Cameras: ', 'Frigate Card CameraManager initialized (Cameras: ',
this._store.getCameras(), this._store.getCameras(),
`, Duration: ${ `, Duration: ${
@@ -284,7 +315,7 @@ export class CameraManager {
}); });
} }
public async getMediaMetadata(hass: HomeAssistant): Promise<MediaMetadata | null> { public async getMediaMetadata(): Promise<MediaMetadata | null> {
const tags: Set<string> = new Set(); const tags: Set<string> = new Set();
const what: Set<string> = new Set(); const what: Set<string> = new Set();
const where: Set<string> = new Set(); const where: Set<string> = new Set();
@@ -295,7 +326,7 @@ export class CameraManager {
cameraIDs: this._store.getCameraIDs(), cameraIDs: this._store.getCameraIDs(),
}; };
const results = await this._handleQuery(hass, query); const results = await this._handleQuery(query);
for (const result of results?.values() ?? []) { for (const result of results?.values() ?? []) {
if (result.metadata.tags) { if (result.metadata.tags) {
@@ -365,47 +396,46 @@ export class CameraManager {
} }
public async getEvents( public async getEvents(
hass: HomeAssistant,
query: EventQuery | EventQuery[], query: EventQuery | EventQuery[],
engineOptions?: EngineOptions, engineOptions?: EngineOptions,
): Promise<EventQueryResultsMap> { ): Promise<EventQueryResultsMap> {
return await this._handleQuery(hass, query, engineOptions); return await this._handleQuery(query, engineOptions);
} }
public async getRecordings( public async getRecordings(
hass: HomeAssistant,
query: RecordingQuery | RecordingQuery[], query: RecordingQuery | RecordingQuery[],
engineOptions?: EngineOptions, engineOptions?: EngineOptions,
): Promise<RecordingQueryResultsMap> { ): Promise<RecordingQueryResultsMap> {
return await this._handleQuery(hass, query, engineOptions); return await this._handleQuery(query, engineOptions);
} }
public async getRecordingSegments( public async getRecordingSegments(
hass: HomeAssistant,
query: RecordingSegmentsQuery | RecordingSegmentsQuery[], query: RecordingSegmentsQuery | RecordingSegmentsQuery[],
engineOptions?: EngineOptions, engineOptions?: EngineOptions,
): Promise<RecordingSegmentsQueryResultsMap> { ): Promise<RecordingSegmentsQueryResultsMap> {
return await this._handleQuery(hass, query, engineOptions); return await this._handleQuery(query, engineOptions);
} }
public async executeMediaQueries<T extends MediaQuery>( public async executeMediaQueries<T extends MediaQuery>(
hass: HomeAssistant,
queries: T[], queries: T[],
engineOptions?: EngineOptions, engineOptions?: EngineOptions,
): Promise<ViewMedia[] | null> { ): Promise<ViewMedia[] | null> {
return this._convertQueryResultsToMedia( return this._convertQueryResultsToMedia(
hass, await this._handleQuery(queries, engineOptions),
await this._handleQuery(hass, queries, engineOptions),
); );
} }
public async extendMediaQueries<T extends MediaQuery>( public async extendMediaQueries<T extends MediaQuery>(
hass: HomeAssistant,
queries: T[], queries: T[],
results: ViewMedia[], results: ViewMedia[],
direction: 'earlier' | 'later', direction: 'earlier' | 'later',
engineOptions?: EngineOptions, engineOptions?: EngineOptions,
): Promise<ExtendedMediaQueryResult<T> | null> { ): Promise<ExtendedMediaQueryResult<T> | null> {
const hass = this._api.getHASSManager().getHASS();
if (!hass) {
return null;
}
const getTimeFromResults = (want: 'earliest' | 'latest'): Date | null => { const getTimeFromResults = (want: 'earliest' | 'latest'): Date | null => {
let output: Date | null = null; let output: Date | null = null;
for (const result of results) { for (const result of results) {
@@ -423,8 +453,8 @@ export class CameraManager {
}; };
const chunkSize = const chunkSize =
this._cardWideConfig?.performance?.features.media_chunk_size ?? this._api.getConfigManager().getCardWideConfig()?.performance?.features
MEDIA_CHUNK_SIZE_DEFAULT; .media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT;
// The queries associated with the chunk to fetch. // The queries associated with the chunk to fetch.
const newChunkQueries: T[] = []; const newChunkQueries: T[] = [];
@@ -456,8 +486,7 @@ export class CameraManager {
} }
const newChunkMedia = this._convertQueryResultsToMedia( const newChunkMedia = this._convertQueryResultsToMedia(
hass, await this._handleQuery(newChunkQueries, engineOptions),
await this._handleQuery(hass, newChunkQueries, engineOptions),
); );
if (!newChunkMedia.length) { if (!newChunkMedia.length) {
@@ -478,14 +507,12 @@ export class CameraManager {
}; };
} }
public async getMediaDownloadPath( public async getMediaDownloadPath(media: ViewMedia): Promise<CameraEndpoint | null> {
hass: ExtendedHomeAssistant,
media: ViewMedia,
): Promise<CameraEndpoint | null> {
const cameraConfig = this._store.getCameraConfigForMedia(media); const cameraConfig = this._store.getCameraConfigForMedia(media);
const engine = this._store.getEngineForMedia(media); const engine = this._store.getEngineForMedia(media);
const hass = this._api.getHASSManager().getHASS();
if (!cameraConfig || !engine) { if (!cameraConfig || !engine || !hass) {
return null; return null;
} }
return await engine.getMediaDownloadPath(hass, cameraConfig, media); return await engine.getMediaDownloadPath(hass, cameraConfig, media);
@@ -499,15 +526,12 @@ export class CameraManager {
return engine.getMediaCapabilities(media); return engine.getMediaCapabilities(media);
} }
public async favoriteMedia( public async favoriteMedia(media: ViewMedia, favorite: boolean): Promise<void> {
hass: HomeAssistant,
media: ViewMedia,
favorite: boolean,
): Promise<void> {
const cameraConfig = this._store.getCameraConfigForMedia(media); const cameraConfig = this._store.getCameraConfigForMedia(media);
const engine = this._store.getEngineForMedia(media); const engine = this._store.getEngineForMedia(media);
const hass = this._api.getHASSManager().getHASS();
if (!cameraConfig || !engine) { if (!cameraConfig || !engine || !hass) {
return; return;
} }
@@ -515,7 +539,7 @@ export class CameraManager {
await engine.favoriteMedia(hass, cameraConfig, media, favorite); await engine.favoriteMedia(hass, cameraConfig, media, favorite);
log( log(
this._cardWideConfig, this._api.getConfigManager().getCardWideConfig(),
'Frigate Card CameraManager favorite request (', 'Frigate Card CameraManager favorite request (',
`Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, `Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`,
'Media:', 'Media:',
@@ -550,16 +574,15 @@ export class CameraManager {
return true; return true;
} }
public async getMediaSeekTime( public async getMediaSeekTime(media: ViewMedia, target: Date): Promise<number | null> {
hass: HomeAssistant,
media: ViewMedia,
target: Date,
): Promise<number | null> {
const startTime = media.getStartTime(); const startTime = media.getStartTime();
const endTime = media.getEndTime(); const endTime = media.getEndTime();
const cameraConfig = this._store.getCameraConfigForMedia(media); const cameraConfig = this._store.getCameraConfigForMedia(media);
const engine = this._store.getEngineForMedia(media); const engine = this._store.getEngineForMedia(media);
const hass = this._api.getHASSManager().getHASS();
if ( if (
!hass ||
!cameraConfig || !cameraConfig ||
!engine || !engine ||
!startTime || !startTime ||
@@ -574,13 +597,17 @@ export class CameraManager {
} }
protected async _handleQuery<QT extends DataQuery>( protected async _handleQuery<QT extends DataQuery>(
hass: HomeAssistant,
query: QT | QT[], query: QT | QT[],
engineOptions?: EngineOptions, engineOptions?: EngineOptions,
): Promise<Map<QT, QueryReturnType<QT>>> { ): Promise<Map<QT, QueryReturnType<QT>>> {
const _queries = arrayify(query); const _queries = arrayify(query);
const results = new Map<QT, QueryReturnType<QT>>(); const results = new Map<QT, QueryReturnType<QT>>();
const queryStartTime = new Date(); const queryStartTime = new Date();
const hass = this._api.getHASSManager().getHASS();
if (!hass) {
return results;
}
const processEngineQuery = async ( const processEngineQuery = async (
engine: CameraManagerEngine, engine: CameraManagerEngine,
@@ -643,7 +670,7 @@ export class CameraManager {
); );
log( log(
this._cardWideConfig, this._api.getConfigManager().getCardWideConfig(),
'Frigate Card CameraManager request [Input queries:', 'Frigate Card CameraManager request [Input queries:',
_queries.length, _queries.length,
', Cached output queries:', ', Cached output queries:',
@@ -662,10 +689,15 @@ export class CameraManager {
} }
protected _convertQueryResultsToMedia<QT extends DataQuery>( protected _convertQueryResultsToMedia<QT extends DataQuery>(
hass: HomeAssistant,
results: ResultsMap<QT>, results: ResultsMap<QT>,
): ViewMedia[] { ): ViewMedia[] {
const mediaArray: ViewMedia[] = []; const mediaArray: ViewMedia[] = [];
const hass = this._api.getHASSManager().getHASS();
if (!hass) {
return mediaArray;
}
for (const [query, result] of results.entries()) { for (const [query, result] of results.entries()) {
const engine = this._store.getEngineOfType(result.engine); const engine = this._store.getEngineOfType(result.engine);
@@ -712,13 +744,12 @@ export class CameraManager {
return engine.getCameraEndpoints(cameraConfig, context); return engine.getCameraEndpoints(cameraConfig, context);
} }
public getCameraMetadata( public getCameraMetadata(cameraID: string): CameraManagerCameraMetadata | null {
hass: HomeAssistant,
cameraID: string,
): CameraManagerCameraMetadata | null {
const cameraConfig = this._store.getCameraConfig(cameraID); const cameraConfig = this._store.getCameraConfig(cameraID);
const engine = this._store.getEngineForCameraID(cameraID); const engine = this._store.getEngineForCameraID(cameraID);
if (!cameraConfig || !engine) { const hass = this._api.getHASSManager().getHASS();
if (!cameraConfig || !engine || !hass) {
return null; return null;
} }
return engine.getCameraMetadata(hass, cameraConfig); return engine.getCameraMetadata(hass, cameraConfig);
@@ -747,9 +778,7 @@ export class CameraManager {
canFavoriteRecordings: perCameraCapabilities.some( canFavoriteRecordings: perCameraCapabilities.some(
(cap) => cap?.canFavoriteRecordings, (cap) => cap?.canFavoriteRecordings,
), ),
canSeek: perCameraCapabilities.some( canSeek: perCameraCapabilities.some((cap) => cap?.canSeek),
(cap) => cap?.canSeek,
),
supportsClips: perCameraCapabilities.some((cap) => cap?.supportsClips), supportsClips: perCameraCapabilities.some((cap) => cap?.supportsClips),
supportsRecordings: perCameraCapabilities.some((cap) => cap?.supportsRecordings), supportsRecordings: perCameraCapabilities.some((cap) => cap?.supportsRecordings),
@@ -1,6 +1,30 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig } from '../../types'; import add from 'date-fns/add';
import endOfDay from 'date-fns/endOfDay';
import parse from 'date-fns/parse';
import startOfDay from 'date-fns/startOfDay';
import orderBy from 'lodash-es/orderBy';
import { CameraConfig } from '../../config/types';
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
import {
BrowseMediaStep,
BrowseMediaTarget,
} from '../../utils/ha/browse-media/browse-media-manager';
import {
BROWSE_MEDIA_CACHE_SECONDS,
BrowseMedia,
MEDIA_CLASS_IMAGE,
MEDIA_CLASS_VIDEO,
RichBrowseMedia,
} from '../../utils/ha/browse-media/types';
import { ViewMedia } from '../../view/media'; import { ViewMedia } from '../../view/media';
import {
BrowseMediaCameraManagerEngine,
getViewMediaFromBrowseMediaArray,
isMediaWithinDates,
} from '../browse-media/engine-browse-media';
import { BrowseMediaMetadata } from '../browse-media/types';
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
import { import {
CameraConfigs, CameraConfigs,
CameraEndpoint, CameraEndpoint,
@@ -19,32 +43,8 @@ import {
QueryResultsType, QueryResultsType,
QueryReturnType, QueryReturnType,
} from '../types'; } from '../types';
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
import {
BrowseMediaStep,
BrowseMediaTarget,
} from '../../utils/ha/browse-media/browse-media-manager';
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
import endOfDay from 'date-fns/endOfDay';
import {
BROWSE_MEDIA_CACHE_SECONDS,
BrowseMedia,
MEDIA_CLASS_IMAGE,
MEDIA_CLASS_VIDEO,
RichBrowseMedia,
} from '../../utils/ha/browse-media/types';
import parse from 'date-fns/parse';
import { MotionEyeEventQueryResults } from './types';
import orderBy from 'lodash-es/orderBy';
import startOfDay from 'date-fns/startOfDay';
import add from 'date-fns/add';
import {
BrowseMediaCameraManagerEngine,
getViewMediaFromBrowseMediaArray,
isMediaWithinDates,
} from '../browse-media/engine-browse-media';
import { BrowseMediaMetadata } from '../browse-media/types';
import motioneyeLogo from './assets/motioneye-logo.svg'; import motioneyeLogo from './assets/motioneye-logo.svg';
import { MotionEyeEventQueryResults } from './types';
class MotionEyeQueryResultsClassifier { class MotionEyeQueryResultsClassifier {
public static isMotionEyeEventQueryResults( public static isMotionEyeEventQueryResults(
+10 -11
View File
@@ -1,4 +1,4 @@
import { CameraConfig } from '../types'; import { CameraConfig } from '../config/types';
import { ViewMedia } from '../view/media'; import { ViewMedia } from '../view/media';
import { CameraManagerEngine } from './engine'; import { CameraManagerEngine } from './engine';
import { CameraConfigs, Engine } from './types'; import { CameraConfigs, Engine } from './types';
@@ -41,6 +41,13 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
this._enginesByType.set(engine.getEngineType(), engine); this._enginesByType.set(engine.getEngineType(), engine);
} }
public reset(): void {
this._allConfigs.clear();
this._visibleConfigs.clear();
this._enginesByCamera.clear();
this._enginesByType.clear();
}
public getCameraConfig(cameraID: string): CameraConfig | null { public getCameraConfig(cameraID: string): CameraConfig | null {
return this._allConfigs.get(cameraID) ?? null; return this._allConfigs.get(cameraID) ?? null;
} }
@@ -74,11 +81,7 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
} }
public getCameraConfigForMedia(media: ViewMedia): CameraConfig | null { public getCameraConfigForMedia(media: ViewMedia): CameraConfig | null {
const cameraID = media.getCameraID(); return this.getCameraConfig(media.getCameraID());
if (!cameraID) {
return null;
}
return this.getCameraConfig(cameraID);
} }
public getEngineOfType(engine: Engine): CameraManagerEngine | null { public getEngineOfType(engine: Engine): CameraManagerEngine | null {
@@ -108,11 +111,7 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
} }
public getEngineForMedia(media: ViewMedia): CameraManagerEngine | null { public getEngineForMedia(media: ViewMedia): CameraManagerEngine | null {
const cameraID = media.getCameraID(); return this.getEngineForCameraID(media.getCameraID());
if (!cameraID) {
return null;
}
return this.getEngineForCameraID(cameraID);
} }
public getAllEngines(): CameraManagerEngine[] { public getAllEngines(): CameraManagerEngine[] {
+1 -1
View File
@@ -1,4 +1,4 @@
import { CameraConfig, FrigateCardView } from '../types'; import { CameraConfig, FrigateCardView } from '../config/types';
import { ViewMedia } from '../view/media'; import { ViewMedia } from '../view/media';
// ==== // ====
+5 -5
View File
@@ -1,13 +1,13 @@
import startOfHour from 'date-fns/startOfHour';
import endOfHour from 'date-fns/endOfHour';
import startOfDay from 'date-fns/startOfDay';
import endOfDay from 'date-fns/endOfDay'; import endOfDay from 'date-fns/endOfDay';
import endOfHour from 'date-fns/endOfHour';
import endOfMinute from 'date-fns/endOfMinute'; import endOfMinute from 'date-fns/endOfMinute';
import { DateRange } from './range'; import startOfDay from 'date-fns/startOfDay';
import startOfHour from 'date-fns/startOfHour';
import orderBy from 'lodash-es/orderBy'; import orderBy from 'lodash-es/orderBy';
import uniqBy from 'lodash-es/uniqBy'; import uniqBy from 'lodash-es/uniqBy';
import { CameraConfig } from '../config/types';
import { ViewMedia } from '../view/media'; import { ViewMedia } from '../view/media';
import { CameraConfig } from '../types'; import { DateRange } from './range';
export const convertRangeToCacheFriendlyTimes = ( export const convertRangeToCacheFriendlyTimes = (
range: DateRange, range: DateRange,
+223
View File
@@ -0,0 +1,223 @@
import {
Actions,
ActionsConfig,
FrigateCardCustomAction,
FRIGATE_CARD_VIEW_DEFAULT,
} from '../config/types.js';
import {
convertActionToFrigateCardCustomAction,
frigateCardHandleActionConfig,
getActionConfigGivenAction,
} from '../utils/action.js';
import { getStreamCameraID } from '../utils/substream.js';
import { CardActionsManagerAPI } from './types.js';
export class ActionsManager {
protected _api: CardActionsManagerAPI;
constructor(api: CardActionsManagerAPI) {
this._api = api;
}
/**
* Merge card-wide and view-specific actions.
* @returns A combined set of action.
*/
public getMergedActions(): ActionsConfig {
const view = this._api.getViewManager().getView();
if (this._api.getMessageManager().hasMessage()) {
return {};
}
const config = this._api.getConfigManager().getConfig();
let specificActions: Actions | undefined = undefined;
if (view?.is('live')) {
specificActions = config?.live.actions;
} else if (view?.isGalleryView()) {
specificActions = config?.media_gallery?.actions;
} else if (view?.isViewerView()) {
specificActions = config?.media_viewer.actions;
} else if (view?.is('image')) {
specificActions = config?.image?.actions;
} else {
return {};
}
return { ...config?.view.actions, ...specificActions };
}
/**
* Handle an human interaction called on an element (e.g. 'tap').
*/
public handleInteraction(interaction: string): void {
const hass = this._api.getHASSManager().getHASS();
const config = this.getMergedActions();
const actionConfig = getActionConfigGivenAction(interaction, config);
if (
hass &&
config &&
interaction &&
// Don't call frigateCardHandleActionConfig() unless there is explicitly an
// action defined (as it uses a default that is unhelpful for views that
// have default tap/click actions).
actionConfig
) {
frigateCardHandleActionConfig(
this._api.getCardElementManager().getElement(),
hass,
config,
interaction,
actionConfig,
);
}
}
public handleActionEvent = (ev: Event): void => {
if (!('detail' in ev)) {
// The event may not actually be a CustomEvent object, but may still have a
// detail field. See:
// https://github.com/custom-cards/custom-card-helpers/blob/master/src/fire-event.ts#L70
return;
}
const frigateCardAction = convertActionToFrigateCardCustomAction(ev.detail);
if (frigateCardAction) {
this.executeAction(frigateCardAction);
}
};
/**
* Execute a card action.
* @param frigateCardAction
* @returns `true` if an action is executed.
*/
public async executeAction(frigateCardAction: FrigateCardCustomAction): Promise<void> {
const config = this._api.getConfigManager().getConfig();
const mediaLoadedInfoManager = this._api.getMediaLoadedInfoManager();
if (
// Command not intended for this card (e.g. query string command).
frigateCardAction.card_id &&
config?.card_id !== frigateCardAction.card_id
) {
return;
}
// Note: This function needs to process (view-related) commands even when
// _view has not yet been initialized (since it may be used to set a view
// via the querystring).
const view = this._api.getViewManager().getView();
const action = frigateCardAction.frigate_card_action;
switch (action) {
case 'default':
this._api.getViewManager().setViewDefault();
break;
case 'clip':
case 'clips':
case 'image':
case 'live':
case 'recording':
case 'recordings':
case 'snapshot':
case 'snapshots':
case 'timeline':
this._api.getViewManager().setViewByParameters({
viewName: action,
cameraID: view?.camera,
});
break;
case 'download':
await this._api.getDownloadManager().downloadViewerMedia();
break;
case 'camera_ui':
this._api.getCameraURLManager().openURL();
break;
case 'expand':
this._api.getExpandManager().toggleExpanded();
break;
case 'fullscreen':
this._api.getFullscreenManager().toggleFullscreen();
break;
case 'menu_toggle':
// This is a rare code path: this would only be used if someone has a
// menu toggle action configured outside of the menu itself (e.g.
// picture elements).
this._api.getCardElementManager().toggleMenu();
break;
case 'camera_select':
const selectCameraID = frigateCardAction.camera;
if (view) {
const viewOnCameraSelect = config?.view.camera_select ?? 'current';
const targetViewName =
viewOnCameraSelect === 'current' ? view.view : viewOnCameraSelect;
const verifiedViewName = this._api
.getViewManager()
.isViewSupportedByCamera(selectCameraID, targetViewName)
? targetViewName
: FRIGATE_CARD_VIEW_DEFAULT;
this._api.getViewManager().setViewByParameters({
viewName: verifiedViewName,
cameraID: selectCameraID,
});
}
break;
case 'live_substream_select': {
this._api.getViewManager().setViewWithSubstream(frigateCardAction.camera);
break;
}
case 'live_substream_off': {
this._api.getViewManager().setViewWithoutSubstream();
break;
}
case 'live_substream_on': {
this._api.getViewManager().setViewWithSubstream();
break;
}
case 'media_player':
const mediaPlayer = frigateCardAction.media_player;
const mediaPlayerController = this._api.getMediaPlayerManager();
const media = view?.queryResults?.getSelectedResult() ?? null;
if (frigateCardAction.media_player_action === 'stop') {
await mediaPlayerController.stop(mediaPlayer);
} else if (view?.is('live')) {
await mediaPlayerController.playLive(mediaPlayer, getStreamCameraID(view));
} else if (view?.isViewerView() && media) {
await mediaPlayerController.playMedia(mediaPlayer, media);
}
break;
case 'diagnostics':
this._api.getViewManager().setViewByParameters({ viewName: 'diagnostics' });
break;
case 'microphone_mute':
this._api.getMicrophoneManager().mute();
break;
case 'microphone_unmute':
await this._api.getMicrophoneManager().unmute();
break;
case 'mute':
await mediaLoadedInfoManager.get()?.player?.mute();
break;
case 'unmute':
await mediaLoadedInfoManager.get()?.player?.unmute();
break;
case 'play':
await mediaLoadedInfoManager.get()?.player?.play();
break;
case 'pause':
await mediaLoadedInfoManager.get()?.player?.pause();
break;
case 'screenshot':
await this._api.getDownloadManager().downloadScreenshot();
break;
case 'display_mode_select':
this._api
.getViewManager()
.setViewWithNewDisplayMode(frigateCardAction.display_mode);
break;
default:
console.warn(`Frigate card received unknown card action: ${action}`);
}
}
}
@@ -0,0 +1,43 @@
import { Timer } from '../utils/timer';
import { CardAutoRefreshAPI } from './types';
export class AutoUpdateManager {
protected _timer = new Timer();
protected _api: CardAutoRefreshAPI;
constructor(api: CardAutoRefreshAPI) {
this._api = api;
}
/**
* Set the update timer to trigger an update refresh every
* `view.update_seconds`.
*/
public startDefaultViewTimer(): void {
this._timer.stop();
const updateSeconds = this._api.getConfigManager().getConfig()
?.view.update_seconds;
if (updateSeconds) {
this._timer.start(updateSeconds, () => {
if (this._isAutomatedUpdateAllowed()) {
this._api.getViewManager().setViewDefault();
} else {
// Not allowed to update this time around, but try again at the next
// interval.
this.startDefaultViewTimer();
}
});
}
}
protected _isAutomatedUpdateAllowed(): boolean {
const triggers = this._api.getTriggersManager();
const config = this._api.getConfigManager().getConfig();
const interactionManager = this._api.getInteractionManager();
return (
!triggers.isTriggered() &&
(config?.view.update_force || !interactionManager.hasInteraction())
);
}
}
@@ -0,0 +1,69 @@
import { Automation, AutomationActions, Automations } from '../config/types.js';
import { localize } from '../localize/localize.js';
import { frigateCardHandleAction } from '../utils/action.js';
import { CardAutomationsAPI } from './types.js';
const MAX_NESTED_AUTOMATION_EXECUTIONS = 10;
export class AutomationsManager {
protected _api: CardAutomationsAPI;
protected _automations: Automations;
protected _priorEvaluations: Map<Automation, boolean> = new Map();
// A counter to avoid infinite loops, increases every time actions are run,
// decreases every time actions are complete.
protected _nestedAutomationExecutions = 0;
constructor(api: CardAutomationsAPI) {
this._api = api;
}
public setAutomationsFromConfig() {
this._automations = this._api
.getConfigManager()
.getNonOverriddenConfig()?.automations;
}
public execute(): void {
const hass = this._api.getHASSManager().getHASS();
// Never execute automations if there's an error (as our automation loop
// avoidance -- which shows as an error -- would not work!).
if (!hass || this._api.getMessageManager().hasErrorMessage()) {
return;
}
const actionsToRun: AutomationActions[] = [];
for (const automation of this._automations ?? []) {
const shouldExecute = this._api
.getConditionsManager()
.evaluateCondition(automation.conditions);
const actions = shouldExecute ? automation.actions : automation.actions_not;
const priorEvaluation = this._priorEvaluations.get(automation);
this._priorEvaluations.set(automation, shouldExecute);
if (shouldExecute !== priorEvaluation && actions) {
actionsToRun.push(actions);
}
}
++this._nestedAutomationExecutions;
if (this._nestedAutomationExecutions > MAX_NESTED_AUTOMATION_EXECUTIONS) {
this._api.getMessageManager().setMessageIfHigherPriority({
type: 'error',
message: localize('error.too_many_automations'),
});
return;
}
actionsToRun.forEach((actions) => {
frigateCardHandleAction(
this._api.getCardElementManager().getElement(),
hass,
{},
actions,
);
});
--this._nestedAutomationExecutions;
}
}
+32
View File
@@ -0,0 +1,32 @@
import { CardCameraURLAPI } from './types';
export class CameraURLManager {
protected _api: CardCameraURLAPI;
constructor(api: CardCameraURLAPI) {
this._api = api;
}
public openURL(): void {
const url = this.getCameraURL();
if (url) {
window.open(url);
}
}
public hasCameraURL(): boolean {
return !!this.getCameraURL();
}
public getCameraURL(): string | null {
const view = this._api.getViewManager().getView();
const media = view?.queryResults?.getSelectedResult() ?? null;
const endpoints = view?.camera
? this._api.getCameraManager().getCameraEndpoints(view.camera, {
view: view.view,
...(media && { media: media }),
}) ?? null
: null;
return endpoints?.ui?.endpoint ?? null;
}
}
+117
View File
@@ -0,0 +1,117 @@
import { LitElement, ReactiveControllerHost } from 'lit';
import { ActionEventTarget } from '../action-handler-directive';
import { setOrRemoveAttribute } from '../utils/basic';
import { isCardInPanel } from '../utils/ha';
import { CardElementAPI } from './types';
export type ScrollCallback = () => void;
export type MenuToggleCallback = () => void;
export type CardHTMLElement = LitElement & ReactiveControllerHost & ActionEventTarget;
export class CardElementManager {
protected _api: CardElementAPI;
protected _element: CardHTMLElement;
protected _scrollCallback: ScrollCallback;
protected _menuToggleCallback: MenuToggleCallback;
constructor(
api: CardElementAPI,
element: CardHTMLElement,
scrollCallback: ScrollCallback,
menuToggleCallback: MenuToggleCallback,
) {
this._api = api;
this._element = element;
this._scrollCallback = scrollCallback;
this._menuToggleCallback = menuToggleCallback;
}
public getElement(): HTMLElement {
return this._element;
}
public scrollReset(): void {
this._scrollCallback();
}
public toggleMenu(): void {
this._menuToggleCallback();
}
public update(): void {
this._element.requestUpdate();
}
public hasUpdated(): boolean {
return this._element.hasUpdated;
}
public getCardHeight(): number {
return this._element.getBoundingClientRect().height;
}
public elementConnected(): void {
// Whether or not the card is in panel mode on the dashboard.
setOrRemoveAttribute(this._element, isCardInPanel(this._element), 'panel');
this._api.getFullscreenManager().connect();
this._element.addEventListener(
'mousemove',
this._api.getInteractionManager().reportInteraction,
);
this._element.addEventListener(
'll-custom',
this._api.getActionsManager().handleActionEvent,
);
this._element.addEventListener(
'@action',
this._api.getInteractionManager().reportInteraction,
);
// Listen for HA `navigate` actions.
// See: https://github.com/home-assistant/frontend/blob/273992c8e9c3062c6e49481b6d7d688a07067232/src/common/navigate.ts#L43
window.addEventListener(
'location-changed',
this._api.getQueryStringManager().executeAll,
);
// Listen for history state changes (i.e. user using the browser
// back/forward controls).
window.addEventListener('popstate', this._api.getQueryStringManager().executeAll);
// Manually call the location change handler as the card will be
// disconnected/reconnected when dashboard 'tab' changes happen within HA.
this._api.getQueryStringManager().executeAll();
}
public elementDisconnected(): void {
setOrRemoveAttribute(this._element, false, 'panel');
// When the dashboard 'tab' is changed, the media is effectively unloaded.
this._api.getMediaLoadedInfoManager().clear();
this._api.getFullscreenManager().disconnect();
this._element.removeEventListener(
'mousemove',
this._api.getInteractionManager().reportInteraction,
);
this._element.removeEventListener(
'll-custom',
this._api.getActionsManager().handleActionEvent,
);
this._element.removeEventListener(
'@action',
this._api.getInteractionManager().reportInteraction,
);
window.removeEventListener(
'location-changed',
this._api.getQueryStringManager().executeAll,
);
window.removeEventListener('popstate', this._api.getQueryStringManager().executeAll);
}
}
@@ -1,14 +1,14 @@
import { HassEntities } from 'home-assistant-js-websocket'; import { HassEntities } from 'home-assistant-js-websocket';
import merge from 'lodash-es/merge'; import merge from 'lodash-es/merge';
import { copyConfig } from './config-mgmt'; import { copyConfig } from '../config-mgmt';
import { import {
FrigateCardCondition, FrigateCardCondition,
FrigateCardConfig,
frigateConditionalSchema, frigateConditionalSchema,
OverrideConfigurationKey, OverrideConfigurationKey,
RawFrigateCardConfig, RawFrigateCardConfig,
ViewDisplayMode ViewDisplayMode
} from './types'; } from '../config/types';
import { CardConditionAPI } from './types';
interface ConditionState { interface ConditionState {
view?: string; view?: string;
@@ -69,7 +69,7 @@ type RawOverrides = {
}[]; }[];
export function getOverriddenConfig( export function getOverriddenConfig(
controller: Readonly<ConditionController>, manager: Readonly<ConditionsManager>,
config: Readonly<RawFrigateCardConfig>, config: Readonly<RawFrigateCardConfig>,
configOverrides?: Readonly<RawOverrides>, configOverrides?: Readonly<RawOverrides>,
stateOverrides?: Partial<ConditionState>, stateOverrides?: Partial<ConditionState>,
@@ -78,7 +78,7 @@ export function getOverriddenConfig(
let overridden = false; let overridden = false;
if (configOverrides) { if (configOverrides) {
for (const override of configOverrides) { for (const override of configOverrides) {
if (controller.evaluateCondition(override.conditions, stateOverrides)) { if (manager.evaluateCondition(override.conditions, stateOverrides)) {
merge(output, override.overrides); merge(output, override.overrides);
overridden = true; overridden = true;
} }
@@ -103,19 +103,23 @@ export function getOverridesByKey(
); );
} }
// A tiny wrapper interface to allow the same controller to be passed around // A tiny wrapper interface to allow the same manager to be passed around
// immutably within objects that will not be equal (===). Every state change // immutably within objects that will not be equal (===). Every state change
// generates a new epoch. This is used for Lit rendering to ensure changes to // generates a new epoch. This is used for Lit rendering to ensure changes to
// condition state are recognized as changes even though the controller is the // condition state are recognized as changes even though the manager is the
// same. // same.
export interface ConditionControllerEpoch { export interface ConditionsManagerEpoch {
controller: Readonly<ConditionController>; manager: Readonly<ConditionsManager>;
} }
export class ConditionController { export type ConditionsManagerListener = () => void;
export class ConditionsManager {
protected _api: CardConditionAPI;
protected _state: ConditionState = {}; protected _state: ConditionState = {};
protected _epoch: ConditionControllerEpoch = this._createEpoch(); protected _epoch: ConditionsManagerEpoch = this._createEpoch();
protected _stateListeners: (() => void)[] = []; protected _listeners: ConditionsManagerListener[];
// Whether or not to include HA state in ConditionState. Doing so increases // Whether or not to include HA state in ConditionState. Doing so increases
// CPU usage as HA state is pumped out very fast, so this is only enabled if // CPU usage as HA state is pumped out very fast, so this is only enabled if
@@ -124,29 +128,59 @@ export class ConditionController {
protected _mediaQueries: MediaQueryList[] = []; protected _mediaQueries: MediaQueryList[] = [];
protected _mediaQueryTrigger = () => this._triggerChange(); protected _mediaQueryTrigger = () => this._triggerChange();
constructor(config?: FrigateCardConfig) { constructor(api: CardConditionAPI, listener?: ConditionsManagerListener) {
if (config) { this._api = api;
this._initConditions(config); this._listeners = [
} () => this._api.getConfigManager().computeOverrideConfig(),
() => this._api.getAutomationsManager().execute(),
...(listener ? [listener] : [])
];
} }
public addStateListener(callback: () => void): void { public removeConditions(): void {
this._stateListeners.push(callback);
}
public removeStateListener(callback: () => void): void {
this._stateListeners = this._stateListeners.filter(
(listener) => listener != callback,
);
}
public destroy(): void {
this._mediaQueries.forEach((mql) => this._mediaQueries.forEach((mql) =>
mql.removeEventListener('change', this._mediaQueryTrigger), mql.removeEventListener('change', this._mediaQueryTrigger),
); );
this._mediaQueries = []; this._mediaQueries = [];
} }
public setConditionsFromConfig(): void {
this.removeConditions();
const getAllConditions = (): FrigateCardCondition[] => {
const config = this._api.getConfigManager().getConfig();
const conditions: FrigateCardCondition[] = [];
config?.overrides?.forEach((override) => conditions.push(override.conditions));
// Element conditions can be arbitrarily nested underneath conditionals and
// custom elements that this card may not known. Here we recursively parse
// down the elements tree, parsing as we go to find valid conditions.
const getElementsConditions = (data: unknown): void => {
const parseResult = frigateConditionalSchema.safeParse(data);
if (parseResult.success) {
conditions.push(parseResult.data.conditions);
parseResult.data.elements?.forEach(getElementsConditions);
} else if (data && typeof data === 'object') {
Object.keys(data).forEach((key) => getElementsConditions(data[key]));
}
};
config?.elements?.forEach(getElementsConditions);
return conditions;
};
const conditions = getAllConditions();
this._hasHAStateConditions = conditions.some(
(condition) => !!condition.state?.length,
);
conditions.forEach((condition) => {
if (condition.media_query) {
const mql = window.matchMedia(condition.media_query);
mql.addEventListener('change', this._mediaQueryTrigger);
this._mediaQueries.push(mql);
}
});
}
public setState(state: Partial<ConditionState>): void { public setState(state: Partial<ConditionState>): void {
this._state = { this._state = {
...this._state, ...this._state,
@@ -155,11 +189,11 @@ export class ConditionController {
this._triggerChange(); this._triggerChange();
} }
get hasHAStateConditions(): boolean { public hasHAStateConditions(): boolean {
return this._hasHAStateConditions; return this._hasHAStateConditions;
} }
public getEpoch(): ConditionControllerEpoch { public getEpoch(): ConditionsManagerEpoch {
return this._epoch; return this._epoch;
} }
@@ -212,46 +246,12 @@ export class ConditionController {
return result; return result;
} }
protected _createEpoch(): ConditionControllerEpoch { protected _createEpoch(): ConditionsManagerEpoch {
return { controller: this }; return { manager: this };
} }
protected _triggerChange(): void { protected _triggerChange(): void {
this._epoch = this._createEpoch(); this._epoch = this._createEpoch();
this._stateListeners.forEach((listener) => listener()); this._listeners.forEach((listener) => listener());
}
protected _initConditions(config: FrigateCardConfig): void {
const getAllConditions = (config: FrigateCardConfig): FrigateCardCondition[] => {
const conditions: FrigateCardCondition[] = [];
config.overrides?.forEach((override) => conditions.push(override.conditions));
// Element conditions can be arbitrarily nested underneath conditionals and
// custom elements that this card may not known. Here we recursively parse
// down the elements tree, parsing as we go to find valid conditions.
const getElementsConditions = (data: unknown): void => {
const parseResult = frigateConditionalSchema.safeParse(data);
if (parseResult.success) {
conditions.push(parseResult.data.conditions);
parseResult.data.elements?.forEach(getElementsConditions);
} else if (data && typeof data === 'object') {
Object.keys(data).forEach((key) => getElementsConditions(data[key]));
}
};
config.elements?.forEach(getElementsConditions);
return conditions;
};
const conditions = getAllConditions(config);
this._hasHAStateConditions = conditions.some(
(condition) => !!condition.state?.length,
);
conditions.forEach((condition) => {
if (condition.media_query) {
const mql = window.matchMedia(condition.media_query);
mql.addEventListener('change', this._mediaQueryTrigger);
this._mediaQueries.push(mql);
}
});
} }
} }
+147
View File
@@ -0,0 +1,147 @@
import isEqual from 'lodash-es/isEqual';
import { isConfigUpgradeable } from '../config-mgmt';
import {
CardWideConfig,
FrigateCardConfig,
frigateCardConfigSchema,
RawFrigateCardConfig
} from '../config/types';
import { localize } from '../localize/localize';
import { setLowPerformanceProfile } from '../performance.js';
import { getParseErrorPaths } from '../utils/zod.js';
import { getOverriddenConfig } from './conditions-manager';
import { InitializationAspect } from './initialization-manager';
import { CardConfigAPI } from './types';
export class ConfigManager {
protected _api: CardConfigAPI;
// The main base configuration object. For most usecases use getConfig() to
// get the correct configuration (which will return overrides as appropriate).
// This variable must be called `_config` or `config` to be compatible with
// card-mod.
protected _config: FrigateCardConfig | null = null;
protected _overriddenConfig: FrigateCardConfig | null = null;
protected _rawConfig: RawFrigateCardConfig | null = null;
protected _cardWideConfig: CardWideConfig | null = null;
constructor(api: CardConfigAPI) {
this._api = api;
}
public hasConfig(): boolean {
return !!this.getConfig();
}
public getConfig(): FrigateCardConfig | null {
return this._overriddenConfig ?? this._config;
}
public getCardWideConfig(): CardWideConfig | null {
return this._cardWideConfig;
}
public getNonOverriddenConfig(): FrigateCardConfig | null {
return this._config;
}
public getRawConfig(): RawFrigateCardConfig | null {
return this._rawConfig;
}
public setConfig(inputConfig?: RawFrigateCardConfig): void {
if (!inputConfig) {
throw new Error(localize('error.invalid_configuration'));
}
const parseResult = frigateCardConfigSchema.safeParse(inputConfig);
if (!parseResult.success) {
const configUpgradeable = isConfigUpgradeable(inputConfig);
const hint = getParseErrorPaths(parseResult.error);
let upgradeMessage = '';
if (configUpgradeable) {
upgradeMessage = `${localize('error.upgrade_available')}. `;
}
throw new Error(
upgradeMessage +
`${localize('error.invalid_configuration')}: ` +
(hint && hint.size
? JSON.stringify([...hint], null, ' ')
: localize('error.invalid_configuration_no_hint')),
);
}
const config =
parseResult.data.performance.profile !== 'low'
? parseResult.data
: setLowPerformanceProfile(inputConfig, parseResult.data);
this._rawConfig = inputConfig;
if (isEqual(this._config, config)) {
return;
}
this._config = config;
this._cardWideConfig = {
performance: config.performance,
debug: config.debug,
};
this._api.getConditionsManager().setConditionsFromConfig();
this._api.getConditionsManager().setState({
view: undefined,
displayMode: undefined,
camera: undefined,
});
this._api.getMediaLoadedInfoManager().clear();
this._api.getViewManager().reset();
this._api.getMessageManager().reset();
this._api.getAutomationsManager().setAutomationsFromConfig();
this._api.getStyleManager().setPerformance();
this._api.getCardElementManager().update();
this.computeOverrideConfig();
}
public computeOverrideConfig(): void {
const conditionsManager = this._api.getConditionsManager();
if (!this._config) {
return;
}
const overriddenConfig = getOverriddenConfig(
conditionsManager,
this._config,
this._config.overrides,
) as FrigateCardConfig;
// Save on Lit re-rendering costs by only updating the configuration if it
// actually changes.
if (isEqual(overriddenConfig, this._overriddenConfig)) {
return;
}
const previousConfig = this._overriddenConfig;
this._overriddenConfig = overriddenConfig;
this._api.getStyleManager().setMinMaxHeight();
if (
previousConfig &&
(!isEqual(previousConfig?.cameras, this._overriddenConfig?.cameras) ||
!isEqual(previousConfig?.cameras_global, this._overriddenConfig?.cameras_global))
) {
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
}
if (
previousConfig &&
previousConfig?.live.microphone.always_connected !==
this._overriddenConfig?.live.microphone.always_connected
) {
this._api
.getInitializationManager()
.uninitialize(InitializationAspect.MICROPHONE_CONNECT);
}
this._api.getCardElementManager().update();
}
}
+259
View File
@@ -0,0 +1,259 @@
import { LovelaceCardEditor } from 'custom-card-helpers';
import { ReactiveController } from 'lit';
import { CameraManager } from '../camera-manager/manager';
import { FrigateCardConfig } from '../config/types';
import { EntityRegistryManager } from '../utils/ha/entity-registry';
import { EntityCache } from '../utils/ha/entity-registry/cache';
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
import { ActionsManager } from './actions-manager';
import { AutoUpdateManager } from './auto-update-manager';
import { AutomationsManager } from './automations-manager';
import { CameraURLManager } from './camera-url-manager';
import {
CardElementManager,
CardHTMLElement,
MenuToggleCallback,
ScrollCallback,
} from './card-element-manager';
import { ConditionsManager, ConditionsManagerListener } from './conditions-manager';
import { ConfigManager } from './config-manager';
import { DownloadManager } from './download-manager';
import { ExpandManager } from './expand-manager';
import { FullscreenManager } from './fullscreen-manager';
import { HASSManager } from './hass-manager';
import { InitializationManager } from './initialization-manager';
import { InteractionManager } from './interaction-manager';
import { MediaLoadedInfoManager } from './media-info-manager';
import { MediaPlayerManager } from './media-player-manager';
import { MessageManager } from './message-manager';
import { MicrophoneManager } from './microphone-manager';
import { QueryStringManager } from './query-string-manager';
import { StyleManager } from './style-manager';
import { TriggersManager } from './triggers-manager';
import {
CardActionsManagerAPI,
CardAutoRefreshAPI,
CardAutomationsAPI,
CardCameraAPI,
CardCameraURLAPI,
CardConditionAPI,
CardConfigAPI,
CardDownloadAPI,
CardElementAPI,
CardExpandAPI,
CardFullscreenAPI,
CardHASSAPI,
CardInitializerAPI,
CardInteractionAPI,
CardMediaLoadedAPI,
CardMediaPlayerAPI,
CardMessageAPI,
CardMicrophoneAPI,
CardQueryStringAPI,
CardStyleAPI,
CardTriggersAPI,
CardViewAPI,
} from './types';
import { ViewManager } from './view-manager';
export class CardController
implements
CardActionsManagerAPI,
CardAutomationsAPI,
CardAutoRefreshAPI,
CardCameraAPI,
CardCameraURLAPI,
CardConditionAPI,
CardConfigAPI,
CardDownloadAPI,
CardElementAPI,
CardExpandAPI,
CardFullscreenAPI,
CardHASSAPI,
CardInitializerAPI,
CardInteractionAPI,
CardMediaLoadedAPI,
CardMediaPlayerAPI,
CardMessageAPI,
CardMicrophoneAPI,
CardQueryStringAPI,
CardStyleAPI,
CardTriggersAPI,
CardViewAPI,
ReactiveController
{
// These properties may be used in the construction of 'managers' (and should
// be created first).
protected _entityRegistryManager = new EntityRegistryManager(new EntityCache());
protected _resolvedMediaCache = new ResolvedMediaCache();
protected _actionsManager = new ActionsManager(this);
protected _automationsManager = new AutomationsManager(this);
protected _autoUpdateManager = new AutoUpdateManager(this);
protected _cameraManager = new CameraManager(this);
protected _cameraURLManager = new CameraURLManager(this);
protected _cardElementManager: CardElementManager;
protected _conditionsManager: ConditionsManager;
protected _configManager = new ConfigManager(this);
protected _downloadManager = new DownloadManager(this);
protected _expandManager = new ExpandManager(this);
protected _fullscreenManager = new FullscreenManager(this);
protected _hassManager = new HASSManager(this);
protected _initializationManager = new InitializationManager(this);
protected _interactionManager = new InteractionManager(this);
protected _mediaLoadedInfoManager = new MediaLoadedInfoManager(this);
protected _mediaPlayerManager = new MediaPlayerManager(this);
protected _messageManager = new MessageManager(this);
protected _microphoneManager = new MicrophoneManager(this);
protected _queryStringManager = new QueryStringManager(this);
protected _styleManager = new StyleManager(this);
protected _triggersManager = new TriggersManager(this);
protected _viewManager = new ViewManager(this);
constructor(
host: CardHTMLElement,
scrollCallback: ScrollCallback,
menuToggleCallback: MenuToggleCallback,
conditionListener: ConditionsManagerListener,
) {
host.addController(this);
this._conditionsManager = new ConditionsManager(this, conditionListener);
this._cardElementManager = new CardElementManager(
this,
host,
scrollCallback,
menuToggleCallback,
);
}
// *************************************************************************
// Accessors
// *************************************************************************
public getActionsManager(): ActionsManager {
return this._actionsManager;
}
public getAutomationsManager(): AutomationsManager {
return this._automationsManager;
}
public getAutoUpdateManager(): AutoUpdateManager {
return this._autoUpdateManager;
}
public getCameraManager(): CameraManager {
return this._cameraManager;
}
public getCameraURLManager(): CameraURLManager {
return this._cameraURLManager;
}
public getCardElementManager(): CardElementManager {
return this._cardElementManager;
}
public getConditionsManager(): ConditionsManager {
return this._conditionsManager;
}
public static async getConfigElement(): Promise<LovelaceCardEditor> {
await import('../editor.js');
return document.createElement('frigate-card-editor');
}
public getConfigManager(): ConfigManager {
return this._configManager;
}
public getDownloadManager(): DownloadManager {
return this._downloadManager;
}
public getEntityRegistryManager(): EntityRegistryManager {
return this._entityRegistryManager;
}
public getExpandManager(): ExpandManager {
return this._expandManager;
}
public getFullscreenManager(): FullscreenManager {
return this._fullscreenManager;
}
public getHASSManager(): HASSManager {
return this._hassManager;
}
public getInitializationManager(): InitializationManager {
return this._initializationManager;
}
public getInteractionManager(): InteractionManager {
return this._interactionManager;
}
public getMediaLoadedInfoManager(): MediaLoadedInfoManager {
return this._mediaLoadedInfoManager;
}
public getMediaPlayerManager(): MediaPlayerManager {
return this._mediaPlayerManager;
}
public getMessageManager(): MessageManager {
return this._messageManager;
}
public getMicrophoneManager(): MicrophoneManager {
return this._microphoneManager;
}
public getQueryStringManager(): QueryStringManager {
return this._queryStringManager;
}
public getResolvedMediaCache(): ResolvedMediaCache {
return this._resolvedMediaCache;
}
public static getStubConfig(entities: string[]): FrigateCardConfig {
const cameraEntity = entities.find((element) => element.startsWith('camera.'));
return {
cameras: [
{
camera_entity: cameraEntity ?? 'camera.demo',
},
],
// Need to use 'as unknown' to convince Typescript that this really isn't a
// mistake, despite the miniscule size of the configuration vs the full type
// description.
} as unknown as FrigateCardConfig;
}
public getStyleManager(): StyleManager {
return this._styleManager;
}
public getTriggersManager(): TriggersManager {
return this._triggersManager;
}
public getViewManager(): ViewManager {
return this._viewManager;
}
// *************************************************************************
// Handlers
// *************************************************************************
public hostConnected(): void {
this.getCardElementManager().elementConnected();
}
public hostDisconnected(): void {
this.getCardElementManager().elementDisconnected();
}
}
+41
View File
@@ -0,0 +1,41 @@
import { downloadMedia, downloadURL } from '../utils/download';
import { generateScreenshotTitle } from '../utils/screenshot';
import { CardDownloadAPI } from './types';
export class DownloadManager {
protected _api: CardDownloadAPI;
constructor(api: CardDownloadAPI) {
this._api = api;
}
public async downloadViewerMedia(): Promise<boolean> {
const media = this._api
.getViewManager()
.getView()
?.queryResults?.getSelectedResult();
const hass = this._api.getHASSManager().getHASS();
if (!media || !hass) {
return false;
}
try {
await downloadMedia(hass, this._api.getCameraManager(), media);
} catch (error: unknown) {
this._api.getMessageManager().setErrorIfHigherPriority(error);
return false;
}
return true;
}
public async downloadScreenshot(): Promise<void> {
const url = await this._api
.getMediaLoadedInfoManager()
.get()
?.player?.getScreenshotURL();
if (url) {
downloadURL(url, generateScreenshotTitle(this._api.getViewManager().getView()));
}
}
}
+31
View File
@@ -0,0 +1,31 @@
import { CardExpandAPI } from './types';
export class ExpandManager {
protected _expanded = false;
protected _api: CardExpandAPI;
constructor(api: CardExpandAPI) {
this._api = api;
}
public isExpanded(): boolean {
return this._expanded;
}
public toggleExpanded(): void {
this.setExpanded(!this._expanded);
}
public setExpanded(expanded: boolean): void {
if (expanded && this._api.getFullscreenManager().isInFullscreen()) {
// Fullscreen and expanded mode are mutually exclusive.
this._api.getFullscreenManager().stopFullscreen();
}
this._expanded = expanded;
this._api.getConditionsManager()?.setState({
expand: expanded,
});
this._api.getCardElementManager().update();
}
}
+47
View File
@@ -0,0 +1,47 @@
import screenfull from 'screenfull';
import { CardFullscreenAPI } from './types';
export class FullscreenManager {
protected _api: CardFullscreenAPI;
constructor(api: CardFullscreenAPI) {
this._api = api;
}
public connect(): void {
if (screenfull.isEnabled) {
screenfull.on('change', this._fullscreenHandler);
}
}
public disconnect(): void {
if (screenfull.isEnabled) {
screenfull.off('change', this._fullscreenHandler);
}
}
public isInFullscreen(): boolean {
return screenfull.isEnabled && screenfull.isFullscreen;
}
public toggleFullscreen(): void {
screenfull.toggle(this._api.getCardElementManager().getElement());
}
public stopFullscreen(): void {
screenfull.exit();
}
protected _fullscreenHandler = (): void => {
this._api.getExpandManager().setExpanded(false);
this._api.getConditionsManager()?.setState({
fullscreen: this.isInFullscreen(),
});
// Re-render after a change to fullscreen mode to take advantage of
// the expanded screen real-estate (vs staying in aspect-ratio locked
// modes).
this._api.getCardElementManager().update();
};
}
+89
View File
@@ -0,0 +1,89 @@
import { CameraConfig } from '../config/types';
import { localize } from '../localize/localize';
import { ExtendedHomeAssistant } from '../types';
import { hasHAConnectionStateChanged, isHassDifferent } from '../utils/ha';
import { CardHASSAPI } from './types';
export class HASSManager {
protected _hass: ExtendedHomeAssistant | null = null;
protected _api: CardHASSAPI;
constructor(api: CardHASSAPI) {
this._api = api;
}
public getHASS(): ExtendedHomeAssistant | null {
return this._hass;
}
public setHASS(hass: ExtendedHomeAssistant): void {
const getSelectedCameraConfig = (): CameraConfig | null => {
const view = this._api.getViewManager().getView();
const cameraManager = this._api.getCameraManager();
return view && cameraManager
? cameraManager?.getStore().getCameraConfig(view.camera) ?? null
: null;
};
const oldHass = this._hass;
this._hass = hass;
const selectedCamera = getSelectedCameraConfig();
if (hasHAConnectionStateChanged(oldHass, hass)) {
if (!this._hass?.connected) {
this._api.getMessageManager().setMessageIfHigherPriority({
message: localize('error.reconnecting'),
icon: 'mdi:lan-disconnect',
type: 'connection',
dotdotdot: true,
});
} else {
this._api.getViewManager().setViewDefault();
}
} else if (
// Home Assistant pumps a lot of updates through. Re-rendering the card is
// necessary at times (e.g. to update the 'clip' view as new clips
// arrive), but also is a jarring experience for the user (e.g. if they
// are browsing the mini-gallery). Do not allow re-rendering from a Home
// Assistant update if there's been recent interaction (e.g. clicks on the
// card) or if there is media active playing.
this._isAutomatedViewUpdateAllowed() &&
isHassDifferent(this._hass, oldHass, [
...(this._api.getConfigManager().getConfig()?.view.update_entities ?? []),
...(selectedCamera?.triggers.entities ?? []),
])
) {
// If entities being monitored have changed then reset the view to the
// default.
this._api.getViewManager().setViewDefault();
} else if (
isHassDifferent(this._hass, oldHass, [
...(this._api.getConfigManager().getConfig()?.view.render_entities ?? []),
// Refresh the card if media player state changes:
// https://github.com/dermotduffy/frigate-hass-card/issues/881
...this._api.getMediaPlayerManager().getMediaPlayers(),
])
) {
this._api.getCardElementManager().update();
}
this._api.getTriggersManager().updateTriggeredCameras(oldHass);
if (this._api.getConditionsManager().hasHAStateConditions()) {
this._api.getConditionsManager().setState({ state: this._hass.states });
}
// Dark mode may depend on HASS.
this._api.getStyleManager().setLightOrDarkMode();
}
protected _isAutomatedViewUpdateAllowed(): boolean {
return (
this._api.getConfigManager().getConfig()?.view.update_force ||
!this._api.getInteractionManager().hasInteraction()
);
}
}
@@ -0,0 +1,134 @@
import { loadLanguages } from '../localize/localize';
import { sideLoadHomeAssistantElements } from '../utils/ha';
import { Initializer } from '../utils/initializer/initializer';
import { CardInitializerAPI } from './types';
export enum InitializationAspect {
LANGUAGES = 'languages',
SIDE_LOAD_ELEMENTS = 'side-load-elements',
MEDIA_PLAYERS = 'media-players',
CAMERAS = 'cameras',
MICROPHONE_CONNECT = 'microphone-connect',
}
export class InitializationManager {
protected _api: CardInitializerAPI;
protected _initializer;
constructor(api: CardInitializerAPI, initializer?: Initializer) {
this._api = api;
this._initializer = initializer ?? new Initializer();
}
public isInitializedMandatory(): boolean {
return this._initializer.isInitializedMultiple([
InitializationAspect.LANGUAGES,
InitializationAspect.SIDE_LOAD_ELEMENTS,
InitializationAspect.CAMERAS,
]);
}
/**
* Initialize the hard requirements for rendering anything.
* @returns `true` if card rendering can continue.
*/
public async initializeMandatory(): Promise<boolean> {
const hass = this._api.getHASSManager().getHASS();
if (!hass) {
return false;
}
if (
!(await this._initializer.initializeMultipleIfNecessary({
// Caution: Ensure nothing in this set of initializers requires
// config or languages since they will not yet have been initialized.
[InitializationAspect.LANGUAGES]: async () => await loadLanguages(hass),
[InitializationAspect.SIDE_LOAD_ELEMENTS]: async () =>
await sideLoadHomeAssistantElements(),
}))
) {
return false;
}
if (!this._api.getConfigManager().hasConfig()) {
return false;
}
if (
!(await this._initializer.initializeIfNecessary(
InitializationAspect.CAMERAS,
async () => await this._api.getCameraManager().initializeCamerasFromConfig(),
))
) {
return false;
}
if (!this._api.getMessageManager().hasMessage()) {
// Set a view on initial load. However, if the query string contains a
// view related action, we don't set any view here and allow that content
// to be triggered by the firstUpdated() call that runs query string
// actions. To do otherwise may cause a race condition between the default
// view and the querystring view, see:
// https://github.com/dermotduffy/frigate-hass-card/issues/1200
const hasViewRelatedActions = this._api
.getQueryStringManager()
.hasViewRelatedActions();
if (hasViewRelatedActions) {
this._api.getQueryStringManager().executeViewRelated();
} else {
this._api.getViewManager().setViewDefault();
}
}
this._api.getCardElementManager().update();
return true;
}
/**
* Initialize aspects of the card that can load in the 'background'.
* @returns `true` if card rendering can continue.
*/
public async initializeBackgroundIfNecessary(): Promise<boolean> {
const hass = this._api.getHASSManager().getHASS();
const config = this._api.getConfigManager().getConfig();
if (!hass || !config) {
return false;
}
if (
this._initializer.isInitializedMultiple([
...(config.menu.buttons.media_player.enabled
? [InitializationAspect.MEDIA_PLAYERS]
: []),
...(config.live.microphone.always_connected
? [InitializationAspect.MICROPHONE_CONNECT]
: []),
])
) {
return true;
}
if (
!(await this._initializer.initializeMultipleIfNecessary({
...(config.menu.buttons.media_player.enabled && {
[InitializationAspect.MEDIA_PLAYERS]: async () =>
await this._api.getMediaPlayerManager().initialize(),
}),
...(config.live.microphone.always_connected && {
[InitializationAspect.MICROPHONE_CONNECT]: async () =>
await this._api.getMicrophoneManager().connect(),
}),
}))
) {
return false;
}
this._api.getCardElementManager().update();
return true;
}
public uninitialize(aspect: InitializationAspect) {
return this._initializer.uninitialize(aspect);
}
}
@@ -0,0 +1,50 @@
import throttle from 'lodash-es/throttle';
import { Timer } from '../utils/timer';
import { CardInteractionAPI } from './types';
export class InteractionManager {
protected _timer = new Timer();
protected _api: CardInteractionAPI;
constructor(api: CardInteractionAPI) {
this._api = api;
}
// The mouse handler may be called continually, throttle it to at most once
// per second for performance reasons.
public reportInteraction = throttle(() => {
this._reportInteraction();
}, 1 * 1000);
public hasInteraction(): boolean {
return this._timer.isRunning();
}
/**
* Start the user interaction ('screensaver') timer to reset the view to
* default `view.timeout_seconds` after user interaction.
*/
protected _reportInteraction(): void {
this._timer.stop();
// Interactions reset the trigger state.
this._api.getTriggersManager().untrigger();
const timeoutSeconds = this._api.getConfigManager().getConfig()
?.view.timeout_seconds;
if (timeoutSeconds) {
this._timer.start(timeoutSeconds, () => {
if (this._isAutomatedUpdateAllowed()) {
this._api.getViewManager().setViewDefault();
this._api.getStyleManager().setLightOrDarkMode();
}
});
}
this._api.getStyleManager().setLightOrDarkMode();
}
protected _isAutomatedUpdateAllowed(): boolean {
return !this._api.getTriggersManager().isTriggered();
}
}
+52
View File
@@ -0,0 +1,52 @@
import { MediaLoadedInfo } from '../types';
import { log } from '../utils/debug';
import { isValidMediaLoadedInfo } from '../utils/media-info';
import { CardMediaLoadedAPI } from './types';
export class MediaLoadedInfoManager {
protected _api: CardMediaLoadedAPI;
protected _current: MediaLoadedInfo | null = null;
protected _lastKnown: MediaLoadedInfo | null = null;
constructor(api: CardMediaLoadedAPI) {
this._api = api;
}
public set(mediaInfo: MediaLoadedInfo): void {
if (!isValidMediaLoadedInfo(mediaInfo)) {
return;
}
log(
this._api.getConfigManager().getCardWideConfig(),
`Frigate Card media load: `,
mediaInfo,
);
this._current = mediaInfo;
this._lastKnown = mediaInfo;
this._api.getConditionsManager().setState({ media_loaded: true });
// Fresh media information may change how the card is rendered.
this._api.getStyleManager().setExpandedMode();
this._api.getCardElementManager().update();
}
public get(): MediaLoadedInfo | null {
return this._current;
}
public getLastKnown(): MediaLoadedInfo | null {
return this._lastKnown;
}
public clear(): void {
this._current = null;
this._api.getConditionsManager().setState({ media_loaded: false });
}
public has(): boolean {
return !!this._current;
}
}
+125
View File
@@ -0,0 +1,125 @@
import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA } from '../const';
import { ViewMedia } from '../view/media';
import { ViewMediaClassifier } from '../view/media-classifier';
import { errorToConsole } from '../utils/basic';
import { Entity } from '../utils/ha/entity-registry/types';
import { supportsFeature } from '../utils/ha/update';
import { CardMediaPlayerAPI } from './types';
export class MediaPlayerManager {
protected _mediaPlayers: string[] = [];
protected _api: CardMediaPlayerAPI;
constructor(api: CardMediaPlayerAPI) {
this._api = api;
}
public getMediaPlayers(): string[] {
return this._mediaPlayers;
}
public hasMediaPlayers(): boolean {
return this._mediaPlayers.length > 0;
}
public async initialize(): Promise<void> {
const hass = this._api.getHASSManager().getHASS();
if (!hass) {
return;
}
const isValidMediaPlayer = (entityID: string): boolean => {
if (entityID.startsWith('media_player.')) {
const stateObj = hass.states[entityID];
if (
stateObj &&
stateObj.state !== 'unavailable' &&
supportsFeature(stateObj, MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA)
) {
return true;
}
}
return false;
};
const mediaPlayers = Object.keys(hass.states).filter(isValidMediaPlayer);
let mediaPlayerEntities: Map<string, Entity> | null = null;
try {
mediaPlayerEntities = await this._api
.getEntityRegistryManager()
.getEntities(hass, mediaPlayers);
} catch (e) {
// Failing to fetch media player information is not considered
// sufficiently serious to block card startup -- it is just logged and we
// move on.
errorToConsole(e as Error);
}
// Filter out entities that are marked as hidden (this information is not
// available in the HA state, only in the registry).
this._mediaPlayers = mediaPlayers.filter((entityID) => {
// Specifically allow for media players that are not found in the entity registry:
// See: https://github.com/dermotduffy/frigate-hass-card/issues/1016
const entity = mediaPlayerEntities?.get(entityID);
return !entity || !entity.hidden_by;
});
}
public async stop(mediaPlayer: string): Promise<void> {
await this._api
.getHASSManager()
.getHASS()
?.callService('media_player', 'media_stop', {
entity_id: mediaPlayer,
});
}
public async playLive(mediaPlayer: string, cameraID: string): Promise<void> {
const hass = this._api.getHASSManager().getHASS();
const cameraConfig = this._api
.getCameraManager()
.getStore()
.getCameraConfig(cameraID);
const cameraEntity = cameraConfig?.camera_entity ?? null;
if (!hass || !cameraEntity) {
return;
}
const title =
this._api.getCameraManager().getCameraMetadata(cameraID)?.title ?? null;
const thumbnail = hass.states[cameraEntity]?.attributes?.entity_picture ?? null;
await hass.callService('media_player', 'play_media', {
entity_id: mediaPlayer,
media_content_id: `media-source://camera/${cameraEntity}`,
media_content_type: 'application/vnd.apple.mpegurl',
extra: {
...(title && { title: title }),
...(thumbnail && { thumb: thumbnail }),
},
});
}
public async playMedia(mediaPlayer: string, media?: ViewMedia | null): Promise<void> {
const hass = this._api.getHASSManager().getHASS();
if (!hass || !media) {
return;
}
const title = media.getTitle();
const thumbnail = media.getThumbnail();
await hass.callService('media_player', 'play_media', {
entity_id: mediaPlayer,
media_content_id: media.getContentID(),
media_content_type: ViewMediaClassifier.isVideo(media) ? 'video' : 'image',
extra: {
...(title && { title: title }),
...(thumbnail && { thumb: thumbnail }),
},
});
}
}
+67
View File
@@ -0,0 +1,67 @@
import { FrigateCardError, MESSAGE_TYPE_PRIORITIES, Message } from '../types';
import { errorToConsole } from '../utils/basic';
import { CardMessageAPI } from './types';
export class MessageManager {
protected _message: Message | null = null;
protected _api: CardMessageAPI;
constructor(api: CardMessageAPI) {
this._api = api;
}
public getMessage(): Message | null {
return this._message;
}
public hasMessage(): boolean {
return !!this._message;
}
public hasErrorMessage(): boolean {
return this._message?.type === 'error';
}
public reset(): void {
const hadMessage = this.hasMessage();
this._message = null;
if (hadMessage) {
this._api.getCardElementManager().update();
}
}
public setErrorIfHigherPriority(error: unknown): void {
// This object should accept unknown objects to be able to seamlessly
// process arguments to catch() which can only be unknown/any.
if (!(error instanceof Error)) {
return;
}
errorToConsole(error);
this.setMessageIfHigherPriority({
message: error.message,
type: 'error',
...(error instanceof FrigateCardError && { context: error.context }),
});
}
public setMessageIfHigherPriority(message: Message): boolean {
const currentPriority = this._message
? MESSAGE_TYPE_PRIORITIES[this._message.type]
: 0;
const newPriority = MESSAGE_TYPE_PRIORITIES[message.type];
if (this._message && newPriority < currentPriority) {
return false;
}
this._message = message;
// When a message is displayed it effectively unloads the media.
this._api.getMediaLoadedInfoManager().clear();
this._api.getCardElementManager().scrollReset();
this._api.getCardElementManager().update();
return true;
}
}
@@ -1,7 +1,9 @@
import { errorToConsole } from './basic'; import { errorToConsole } from '../utils/basic';
import { Timer } from './timer'; import { Timer } from '../utils/timer';
import { CardMicrophoneAPI } from './types';
export class MicrophoneController { export class MicrophoneManager {
protected _api: CardMicrophoneAPI;
protected _stream?: MediaStream | null; protected _stream?: MediaStream | null;
protected _timer = new Timer(); protected _timer = new Timer();
@@ -10,10 +12,8 @@ export class MicrophoneController {
// have the right mute status. // have the right mute status.
protected _mute = true; protected _mute = true;
protected _disconnectSeconds: number; constructor(api: CardMicrophoneAPI) {
this._api = api;
constructor(disconnectSeconds?: number) {
this._disconnectSeconds = disconnectSeconds ?? 0;
} }
public async connect(): Promise<void> { public async connect(): Promise<void> {
@@ -32,6 +32,8 @@ export class MicrophoneController {
public async disconnect(): Promise<void> { public async disconnect(): Promise<void> {
this._stream?.getTracks().forEach((track) => track.stop()); this._stream?.getTracks().forEach((track) => track.stop());
this._stream = undefined; this._stream = undefined;
this._api.getCardElementManager().update();
} }
public getStream(): MediaStream | undefined { public getStream(): MediaStream | undefined {
@@ -43,6 +45,8 @@ export class MicrophoneController {
track.enabled = !this._mute; track.enabled = !this._mute;
}); });
this._startTimer(); this._startTimer();
this._api.getCardElementManager().update();
} }
public mute(): void { public mute(): void {
@@ -50,9 +54,24 @@ export class MicrophoneController {
this._setMute(); this._setMute();
} }
public unmute(): void { public async unmute(): Promise<void> {
this._mute = false; const unmute = (): void => {
this._setMute(); this._mute = false;
this._setMute();
};
if (!this.isConnected() && !this.isForbidden()) {
// The connect() call is async and make take an arbitrary amount of
// time for the user to grant access to their microphone. With a
// momentary microphone button the mute call (on mouse release) may
// arrive before the connection is even granted, so we unmute first
// before the connection is made, so the mute call on release will not
// be 'overwritten' incorrectly.
unmute();
await this.connect();
} else if (this.isConnected()) {
unmute();
}
} }
public isConnected(): boolean { public isConnected(): boolean {
@@ -70,8 +89,17 @@ export class MicrophoneController {
} }
protected _startTimer(): void { protected _startTimer(): void {
if (this._disconnectSeconds) { const microphoneConfig = this._api.getConfigManager().getConfig()
this._timer.start(this._disconnectSeconds, () => { ?.live.microphone;
if (microphoneConfig?.always_connected) {
return;
}
const disconnectSeconds = microphoneConfig?.disconnect_seconds ?? 0;
if (disconnectSeconds) {
this._timer.start(disconnectSeconds, () => {
this.disconnect(); this.disconnect();
}); });
} }
+160
View File
@@ -0,0 +1,160 @@
import { FrigateCardCustomAction, FrigateCardViewAction } from '../config/types';
import { createFrigateCardCustomAction } from '../utils/action.js';
import { CardQueryStringAPI } from './types';
import { ViewManagerSetViewParameters } from './view-manager';
interface QueryStringViewIntent {
view?: ViewManagerSetViewParameters & {
default?: boolean;
};
other?: FrigateCardCustomAction[];
}
export class QueryStringManager {
protected _api: CardQueryStringAPI;
constructor(api: CardQueryStringAPI) {
this._api = api;
}
public hasViewRelatedActions(): boolean {
return !!this._calculateIntent().view;
}
public executeNonViewRelated(): void {
this._executeNonViewRelated(this._calculateIntent());
}
public executeViewRelated(): void {
this._executeViewRelated(this._calculateIntent());
}
public executeAll(): void {
const intent = this._calculateIntent();
this._executeViewRelated(intent);
this._executeNonViewRelated(intent);
}
protected _executeViewRelated(intent: QueryStringViewIntent): void {
if (intent.view) {
if (intent.view.default) {
this._api.getViewManager().setViewDefault({
...(intent.view.cameraID && { cameraID: intent.view.cameraID }),
...(intent.view.substream && { substream: intent.view.substream }),
});
} else {
this._api.getViewManager().setViewByParameters({
...(intent.view.viewName && { viewName: intent.view.viewName }),
...(intent.view.cameraID && { cameraID: intent.view.cameraID }),
...(intent.view.substream && { substream: intent.view.substream }),
});
}
}
}
protected _executeNonViewRelated(intent: QueryStringViewIntent): void {
// Only execute non-view actions when the card has rendered at least once.
if (!this._api.getCardElementManager().hasUpdated()) {
return;
}
intent.other?.forEach((action) =>
this._api.getActionsManager().executeAction(action),
);
}
protected _calculateIntent(): QueryStringViewIntent {
const result: QueryStringViewIntent = {};
for (const action of this._getActions()) {
if (this._isViewAction(action)) {
(result.view ??= {}).viewName = action.frigate_card_action;
(result.view ??= {}).default = undefined;
} else if (action.frigate_card_action === 'default') {
(result.view ??= {}).default = true;
(result.view ??= {}).viewName = undefined;
} else if (action.frigate_card_action === 'camera_select') {
(result.view ??= {}).cameraID = action.camera;
} else if (action.frigate_card_action === 'live_substream_select') {
(result.view ??= {}).substream = action.camera;
} else {
(result.other ??= []).push(action);
}
}
return result;
}
protected _getActions(): FrigateCardCustomAction[] {
const params = new URLSearchParams(window.location.search);
const actions: FrigateCardCustomAction[] = [];
const actionRE = new RegExp(
/^frigate-card-action([.:](?<cardID>\w+))?[.:](?<action>\w+)/,
);
for (const [key, value] of params.entries()) {
const match = key.match(actionRE);
if (!match || !match.groups) {
continue;
}
const cardID: string | undefined = match.groups['cardID'];
const action = match.groups['action'];
let customAction: FrigateCardCustomAction | null = null;
switch (action) {
case 'camera_select':
case 'live_substream_select':
if (value) {
customAction = createFrigateCardCustomAction(action, {
camera: value,
cardID: cardID,
});
}
break;
case 'camera_ui':
case 'clip':
case 'clips':
case 'default':
case 'diagnostics':
case 'download':
case 'expand':
case 'image':
case 'live':
case 'menu_toggle':
case 'recording':
case 'recordings':
case 'snapshot':
case 'snapshots':
case 'timeline':
customAction = createFrigateCardCustomAction(action, {
cardID: cardID,
});
break;
default:
console.warn(
`Frigate card received unknown card action in query string: ${action}`,
);
}
if (customAction) {
actions.push(customAction);
}
}
return actions;
}
protected _isViewAction = (
action: FrigateCardCustomAction,
): action is FrigateCardViewAction => {
switch (action.frigate_card_action) {
case 'clip':
case 'clips':
case 'diagnostics':
case 'image':
case 'live':
case 'recording':
case 'recordings':
case 'snapshot':
case 'snapshots':
case 'timeline':
return true;
}
return false;
};
}
+126
View File
@@ -0,0 +1,126 @@
import { FrigateCardConfig } from '../config/types';
import { setPerformanceCSSStyles } from '../performance';
import { View } from '../view/view';
import { setOrRemoveAttribute } from '../utils/basic';
import { CardStyleAPI } from './types';
export class StyleManager {
protected _api: CardStyleAPI;
constructor(api: CardStyleAPI) {
this._api = api;
}
public setLightOrDarkMode(): void {
const config = this._api.getConfigManager().getConfig();
const isDarkMode =
config?.view.dark_mode === 'on' ||
(config?.view.dark_mode === 'auto' &&
(!this._api.getInteractionManager().hasInteraction() ||
!!this._api.getHASSManager().getHASS()?.themes.darkMode));
setOrRemoveAttribute(
this._api.getCardElementManager().getElement(),
isDarkMode,
'dark',
);
}
public setExpandedMode(): void {
const card = this._api.getCardElementManager().getElement();
const view = this._api.getViewManager().getView();
// When a new media loads, set the aspect ratio for when the card is
// expanded/popped-up. This is based exclusively on last media content,
// as dimension configuration does not apply in fullscreen or expanded mode.
const lastKnown = this._api.getMediaLoadedInfoManager().getLastKnown();
card.style.setProperty(
'--frigate-card-expand-aspect-ratio',
view?.isAnyMediaView() && lastKnown
? `${lastKnown.width} / ${lastKnown.height}`
: 'unset',
);
// Non-media may have no intrinsic dimensions (or multiple media items in a
// grid) and so we need to explicit request the dialog to use all available
// space.
const isGrid = view?.isGrid();
card.style.setProperty(
'--frigate-card-expand-width',
!isGrid && view?.isAnyMediaView()
? 'none'
: 'var(--frigate-card-expand-max-width)',
);
card.style.setProperty(
'--frigate-card-expand-height',
!isGrid && view?.isAnyMediaView()
? 'none'
: 'var(--frigate-card-expand-max-height)',
);
}
public setMinMaxHeight(): void {
const config = this._api.getConfigManager().getConfig();
if (config) {
const card = this._api.getCardElementManager().getElement();
card.style.setProperty('--frigate-card-min-height', config.dimensions.min_height);
card.style.setProperty('--frigate-card-max-height', config.dimensions.max_height);
}
}
public setPerformance(): void {
setPerformanceCSSStyles(
this._api.getCardElementManager().getElement(),
this._api.getConfigManager().getCardWideConfig()?.performance,
);
}
protected _isAspectRatioEnforced(
config: FrigateCardConfig,
view?: View | null,
): boolean {
const aspectRatioMode = config.dimensions.aspect_ratio_mode;
// Do not artifically constrain aspect ratio if:
// - It's fullscreen.
// - It's in expanded mode.
// - Aspect ratio enforcement is disabled.
// - Aspect ratio enforcement is dynamic and it's a media view (i.e. not the
// gallery) or diagnostics / timeline.
return !(
this._api.getFullscreenManager().isInFullscreen() ||
this._api.getExpandManager().isExpanded() ||
aspectRatioMode === 'unconstrained' ||
(aspectRatioMode === 'dynamic' &&
(!view ||
view?.isAnyMediaView() ||
view?.is('timeline') ||
view?.is('diagnostics')))
);
}
/**
* Get the aspect ratio padding required to enforce the aspect ratio (if it is
* required).
* @returns A padding percentage.
*/
public getAspectRatioStyle(): string {
const config = this._api.getConfigManager().getConfig();
const view = this._api.getViewManager().getView();
if (config) {
if (!this._isAspectRatioEnforced(config, view)) {
return 'auto';
}
const aspectRatioMode = config.dimensions.aspect_ratio_mode;
const lastKnown = this._api.getMediaLoadedInfoManager().getLastKnown();
if (lastKnown && aspectRatioMode === 'dynamic') {
return `${lastKnown.width} / ${lastKnown.height}`;
}
return `${config.dimensions.aspect_ratio[0]} / ${config.dimensions.aspect_ratio[1]}`;
}
return '16 / 9';
}
}
+126
View File
@@ -0,0 +1,126 @@
import { HomeAssistant } from 'custom-card-helpers';
import orderBy from 'lodash-es/orderBy';
import { getHassDifferences, isTriggeredState } from '../utils/ha';
import { Timer } from '../utils/timer';
import { CardTriggersAPI } from './types';
export class TriggersManager {
protected _api: CardTriggersAPI;
protected _triggers: Map<string, Date> = new Map();
protected _untriggerTimer = new Timer();
constructor(api: CardTriggersAPI) {
this._api = api;
}
public isTriggered(): boolean {
return !!this._triggers.size || this._untriggerTimer.isRunning();
}
public updateTriggeredCameras(oldHass?: HomeAssistant | null): boolean {
if (!this._shouldTrackTriggers()) {
return false;
}
const hass = this._api.getHASSManager().getHASS();
const now = new Date();
let triggerChanges = false;
const cameras = this._api.getCameraManager().getStore().getVisibleCameras();
for (const [cameraID, config] of cameras?.entries()) {
const triggerEntities = config.triggers.entities;
const diffs = getHassDifferences(hass, oldHass, triggerEntities, {
stateOnly: true,
});
const shouldTrigger = diffs.some((diff) => isTriggeredState(diff.newState));
const shouldUntrigger = triggerEntities.every(
(entity) => !isTriggeredState(hass?.states[entity]),
);
if (shouldTrigger) {
this._triggers.set(cameraID, now);
triggerChanges = true;
} else if (shouldUntrigger && this._triggers.has(cameraID)) {
this._triggers.delete(cameraID);
triggerChanges = true;
}
}
if (triggerChanges) {
const targetCameraID = this._getMostRecentTrigger();
if (targetCameraID) {
this._triggerAction(targetCameraID);
return true;
} else {
this._startUntriggerTimer();
}
}
return false;
}
public untrigger(): void {
const wasTriggered = this.isTriggered();
this._triggers.clear();
this._untriggerTimer.stop();
if (wasTriggered) {
this._untriggerAction();
}
}
protected _triggerAction(cameraID: string): void {
const view = this._api.getViewManager().getView();
if (
this._isAutomatedViewUpdateAllowed() &&
(view?.camera !== cameraID || !view?.is('live'))
) {
this._api.getViewManager().setViewByParameters({
viewName: 'live',
cameraID: cameraID,
});
}
}
protected _untriggerAction(): void {
if (
!this.isTriggered() &&
this._isAutomatedViewUpdateAllowed() &&
this._api.getConfigManager().getConfig()?.view.scan.untrigger_reset
) {
this._api.getViewManager().setViewDefault();
}
}
protected _isAutomatedViewUpdateAllowed(): boolean {
return (
this._api.getConfigManager().getConfig()?.view.update_force ||
!this._api.getInteractionManager().hasInteraction()
);
}
protected _shouldTrackTriggers(): boolean {
return !!this._api.getConfigManager().getConfig()?.view.scan.enabled;
}
protected _startUntriggerTimer(): void {
this._untriggerTimer.start(
/* istanbul ignore next: the case of config being null here cannot be
reached, as there's no way to have the untrigger call happen without
a config. -- @preserve */
this._api.getConfigManager().getConfig()?.view.scan.untrigger_seconds ?? 0,
() => {
this._untriggerAction();
},
);
}
protected _getMostRecentTrigger(): string | null {
const sorted = orderBy(
[...this._triggers.entries()],
(entry) => entry[1].getTime(),
'desc',
);
return sorted.length ? sorted[0][0] : null;
}
}
+214
View File
@@ -0,0 +1,214 @@
import { CameraManager } from '../camera-manager/manager';
import { ConditionsManager } from './conditions-manager';
import { EntityRegistryManager } from '../utils/ha/entity-registry';
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
import { ActionsManager } from './actions-manager';
import { AutoUpdateManager } from './auto-update-manager';
import { AutomationsManager } from './automations-manager';
import { CameraURLManager } from './camera-url-manager';
import { CardElementManager } from './card-element-manager';
import { ConfigManager } from './config-manager';
import { DownloadManager } from './download-manager';
import { ExpandManager } from './expand-manager';
import { FullscreenManager } from './fullscreen-manager';
import { HASSManager } from './hass-manager';
import { InitializationManager } from './initialization-manager';
import { InteractionManager } from './interaction-manager';
import { MediaLoadedInfoManager } from './media-info-manager';
import { MediaPlayerManager } from './media-player-manager';
import { MessageManager } from './message-manager';
import { MicrophoneManager } from './microphone-manager';
import { StyleManager } from './style-manager';
import { TriggersManager } from './triggers-manager';
import { ViewManager } from './view-manager';
import { QueryStringManager } from './query-string-manager';
/**
* This defines a series of limited APIs that various manager helpers use to
* control the card. Explicitly specifying them helps make coupling intentional
* and avoids cyclic importing.
*/
export interface CardActionsManagerAPI {
getCameraManager(): CameraManager;
getCameraURLManager(): CameraURLManager;
getCardElementManager(): CardElementManager;
getConfigManager(): ConfigManager;
getDownloadManager(): DownloadManager;
getExpandManager(): ExpandManager;
getFullscreenManager(): FullscreenManager;
getHASSManager(): HASSManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMediaPlayerManager(): MediaPlayerManager;
getMessageManager(): MessageManager;
getViewManager(): ViewManager;
getMicrophoneManager(): MicrophoneManager;
}
export interface CardAutomationsAPI {
getCardElementManager(): CardElementManager;
getConditionsManager(): ConditionsManager;
getConfigManager(): ConfigManager;
getHASSManager(): HASSManager;
getMessageManager(): MessageManager;
}
export interface CardAutoRefreshAPI {
getConfigManager(): ConfigManager;
getViewManager(): ViewManager;
getTriggersManager(): TriggersManager;
getInteractionManager(): InteractionManager;
}
export interface CardCameraAPI {
getConfigManager(): ConfigManager;
getEntityRegistryManager(): EntityRegistryManager;
getResolvedMediaCache(): ResolvedMediaCache;
getHASSManager(): HASSManager;
getMessageManager(): MessageManager;
}
export interface CardCameraURLAPI {
getCameraManager(): CameraManager;
getViewManager(): ViewManager;
}
export interface CardConditionAPI {
getAutomationsManager(): AutomationsManager;
getConfigManager(): ConfigManager;
}
export interface CardConfigAPI {
getAutomationsManager(): AutomationsManager;
getCardElementManager(): CardElementManager;
getConditionsManager(): ConditionsManager;
getInitializationManager(): InitializationManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMessageManager(): MessageManager;
getStyleManager(): StyleManager;
getViewManager(): ViewManager;
}
export interface CardDownloadAPI {
getCameraManager(): CameraManager;
getHASSManager(): HASSManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMessageManager(): MessageManager;
getViewManager(): ViewManager;
}
export interface CardElementAPI {
getActionsManager(): ActionsManager;
getFullscreenManager(): FullscreenManager;
getInteractionManager(): InteractionManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getQueryStringManager(): QueryStringManager;
}
export interface CardExpandAPI {
getFullscreenManager(): FullscreenManager;
getConditionsManager(): ConditionsManager;
getCardElementManager(): CardElementManager;
}
export interface CardFullscreenAPI {
getCardElementManager(): CardElementManager;
getExpandManager(): ExpandManager;
getConditionsManager(): ConditionsManager;
getMediaPlayerManager(): MediaPlayerManager;
}
export interface CardHASSAPI {
getCameraManager(): CameraManager;
getCardElementManager(): CardElementManager;
getConditionsManager(): ConditionsManager;
getConfigManager(): ConfigManager;
getInteractionManager(): InteractionManager;
getMediaPlayerManager(): MediaPlayerManager;
getMessageManager(): MessageManager;
getStyleManager(): StyleManager;
getTriggersManager(): TriggersManager;
getViewManager(): ViewManager;
}
export interface CardInitializerAPI {
getCameraManager(): CameraManager;
getCardElementManager(): CardElementManager;
getConfigManager(): ConfigManager;
getEntityRegistryManager(): EntityRegistryManager;
getHASSManager(): HASSManager;
getMediaPlayerManager(): MediaPlayerManager;
getMessageManager(): MessageManager;
getMicrophoneManager(): MicrophoneManager;
getQueryStringManager(): QueryStringManager;
getResolvedMediaCache(): ResolvedMediaCache;
getViewManager(): ViewManager;
}
export interface CardInteractionAPI {
getConfigManager(): ConfigManager;
getStyleManager(): StyleManager;
getTriggersManager(): TriggersManager;
getViewManager(): ViewManager;
}
export interface CardMediaLoadedAPI {
getConditionsManager(): ConditionsManager;
getConfigManager(): ConfigManager;
getCardElementManager(): CardElementManager;
getStyleManager(): StyleManager;
}
export interface CardMediaPlayerAPI {
getHASSManager(): HASSManager;
getCameraManager(): CameraManager;
getEntityRegistryManager(): EntityRegistryManager;
}
export interface CardMessageAPI {
getConditionsManager(): ConditionsManager;
getCardElementManager(): CardElementManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
}
export interface CardMicrophoneAPI {
getCardElementManager(): CardElementManager;
getConfigManager(): ConfigManager;
}
export interface CardQueryStringAPI {
getCardElementManager(): CardElementManager;
getViewManager(): ViewManager;
getActionsManager(): ActionsManager;
}
export interface CardStyleAPI {
getCardElementManager(): CardElementManager;
getConfigManager(): ConfigManager;
getExpandManager(): ExpandManager;
getFullscreenManager(): FullscreenManager;
getHASSManager(): HASSManager;
getInteractionManager(): InteractionManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getViewManager(): ViewManager;
}
export interface CardTriggersAPI {
getCameraManager(): CameraManager;
getConfigManager(): ConfigManager;
getHASSManager(): HASSManager;
getInteractionManager(): InteractionManager;
getViewManager(): ViewManager;
}
export interface CardViewAPI {
getAutoUpdateManager(): AutoUpdateManager;
getCameraManager(): CameraManager;
getConfigManager(): ConfigManager;
getHASSManager(): HASSManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMessageManager(): MessageManager;
getStyleManager(): StyleManager;
getConditionsManager(): ConditionsManager;
getCardElementManager(): CardElementManager;
}
+286
View File
@@ -0,0 +1,286 @@
import { ViewContext } from 'view';
import { FrigateCardConfig, FrigateCardView, ViewDisplayMode } from '../config/types';
import { View } from '../view/view';
import { getAllDependentCameras } from '../utils/camera';
import { log } from '../utils/debug';
import { executeMediaQueryForView } from '../utils/media-to-view';
import { CardViewAPI } from './types';
interface ViewManagerSetViewDefaultParameters {
cameraID?: string;
substream?: string;
}
export interface ViewManagerSetViewParameters
extends ViewManagerSetViewDefaultParameters {
viewName?: FrigateCardView;
}
export class ViewManager {
protected _view: View | null = null;
protected _api: CardViewAPI;
constructor(api: CardViewAPI) {
this._api = api;
}
public getView(): View | null {
return this._view;
}
public setView(view: View): void {
this._setView(view);
}
public setViewDefault(params?: ViewManagerSetViewDefaultParameters): void {
const config = this._api.getConfigManager().getConfig();
if (config) {
let forceCameraID: string | null = params?.cameraID ?? null;
if (!forceCameraID && this._view?.camera && config.view.update_cycle_camera) {
const cameraIDs = [
...this._api.getCameraManager().getStore().getVisibleCameraIDs(),
];
const currentIndex = cameraIDs.indexOf(this._view.camera);
const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1;
forceCameraID = cameraIDs[targetIndex];
}
this.setViewByParameters({
...params,
viewName: config.view.default,
...(forceCameraID && { cameraID: forceCameraID }),
});
// Restart the refresh timer, so the default view is refreshed at a fixed
// interval from now (if so configured).
this._api.getAutoUpdateManager().startDefaultViewTimer();
}
}
public setViewByParameters(params: ViewManagerSetViewParameters): void {
const config = this._api.getConfigManager().getConfig();
if (config) {
let cameraID: string | null = null;
const cameras = this._api.getCameraManager().getStore().getVisibleCameraIDs();
if (cameras.size) {
if (params?.cameraID && cameras.has(params.cameraID)) {
cameraID = params.cameraID;
} else {
// Reset to the default camera.
cameraID = cameras.keys().next().value;
}
}
const viewName = params?.viewName ?? this._view?.view ?? config.view.default;
if (cameraID && viewName && this.isViewSupportedByCamera(cameraID, viewName)) {
const displayMode =
this._view?.displayMode ??
this._getDefaultDisplayModeForView(viewName, config);
let view: View = new View({
view: viewName,
camera: cameraID,
displayMode: displayMode,
});
if (params.substream) {
view = this._createViewWithSelectedSubstream(view, params.substream);
}
this._setView(view);
}
}
}
public setViewWithNewContext(context: ViewContext): void {
if (this._view) {
return this._setView(this._view?.clone().mergeInContext(context));
}
}
public reset(): void {
this._view = null;
}
public async setViewWithNewDisplayMode(displayMode: ViewDisplayMode): Promise<void> {
const hass = this._api.getHASSManager().getHASS();
if (this._view && hass) {
const view = this._view.evolve({
displayMode: displayMode,
});
const cameraCount = this._api
.getCameraManager()
.getStore()
.getVisibleCameraCount();
const queryCameraCount = view.query?.getQueryCameraIDs()?.size ?? 0;
const generateNewQuery =
view?.query &&
queryCameraCount &&
((view.isGrid() && queryCameraCount < cameraCount) ||
(!view.isGrid() && queryCameraCount > 1));
if (generateNewQuery && view && view.query) {
// If the user requests a grid but the current query does not have a
// query for more than one camera, reset the query results, change the
// existing query to refer to all cameras and execute it to fetch new
// results.
let viewWithNewQuery: View | null = null;
try {
viewWithNewQuery = await executeMediaQueryForView(
this._api.getCameraManager(),
view,
view.query
.clone()
.setQueryCameraIDs(
view.isGrid()
? this._api.getCameraManager().getStore().getVisibleCameraIDs()
: view.camera,
),
);
} catch (e: unknown) {
this._api.getMessageManager().setErrorIfHigherPriority(e);
}
if (viewWithNewQuery) {
return this._setView(viewWithNewQuery);
}
} else {
return this._setView(view);
}
}
}
public setViewWithSubstream(substream?: string): void {
if (!this._view) {
return;
}
this._setView(
substream
? this._createViewWithSelectedSubstream(this._view, substream)
: this._createViewWithNextStream(this._view),
);
}
public setViewWithoutSubstream(): void {
const view = this._createViewWithoutSubstream();
if (view) {
return this._setView(view);
}
}
public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean {
const capabilities = this._api.getCameraManager().getCameraCapabilities(cameraID);
switch (view) {
case 'live':
case 'image':
case 'diagnostics':
return true;
case 'clip':
case 'clips':
return !!capabilities?.supportsClips;
case 'snapshot':
case 'snapshots':
return !!capabilities?.supportsSnapshots;
case 'recording':
case 'recordings':
return !!capabilities?.supportsRecordings;
case 'timeline':
return !!capabilities?.supportsTimeline;
case 'media':
return (
!!capabilities?.supportsClips ||
!!capabilities?.supportsSnapshots ||
!!capabilities?.supportsRecordings
);
}
}
protected _getDefaultDisplayModeForView(
viewName: FrigateCardView,
config?: FrigateCardConfig,
): ViewDisplayMode {
let mode: ViewDisplayMode | null = null;
switch (viewName) {
case 'media':
case 'clip':
case 'recording':
case 'snapshot':
mode = config?.media_viewer.display?.mode ?? null;
break;
case 'live':
mode = config?.live.display?.mode ?? null;
break;
}
return mode ?? 'single';
}
protected _setView(view: View): void {
const oldView = this._view;
View.adoptFromViewIfAppropriate(view, oldView);
log(
this._api.getConfigManager().getCardWideConfig(),
`Frigate Card view change: `,
view.view,
);
this._view = view;
if (View.isMajorMediaChange(oldView, view)) {
this._api.getMediaLoadedInfoManager().clear();
}
if (oldView?.view !== view.view) {
this._api.getCardElementManager().scrollReset();
}
this._api.getMessageManager().reset();
this._api.getStyleManager().setExpandedMode();
this._api.getConditionsManager()?.setState({
view: view.view,
camera: view.camera,
displayMode: view.displayMode ?? undefined,
});
this._api.getCardElementManager().update();
}
protected _createViewWithSelectedSubstream(baseView: View, substreamID: string): View {
const overrides: Map<string, string> =
baseView?.context?.live?.overrides ?? new Map();
overrides.set(baseView.camera, substreamID);
return baseView.clone().mergeInContext({
live: { overrides: overrides },
});
}
protected _createViewWithNextStream(baseView: View): View {
const dependencies = [
...getAllDependentCameras(this._api.getCameraManager(), baseView.camera),
];
if (dependencies.length <= 1) {
return baseView.clone();
}
const view = baseView.clone();
const overrides: Map<string, string> = view.context?.live?.overrides ?? new Map();
const currentOverride = overrides.get(view.camera) ?? view.camera;
const currentIndex = dependencies.indexOf(currentOverride);
const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
overrides.set(view.camera, dependencies[newIndex]);
view.mergeInContext({ live: { overrides: overrides } });
return view;
}
protected _createViewWithoutSubstream(): View | null {
if (!this._view) {
return null;
}
const view = this._view.clone();
const overrides: Map<string, string> | undefined = view.context?.live?.overrides;
if (overrides && overrides.has(view.camera)) {
view.context?.live?.overrides?.delete(view.camera);
}
return view;
}
}
+127 -1677
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -9,8 +9,8 @@ import {
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { Ref, createRef, ref } from 'lit/directives/ref.js'; import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { TransitionEffect } from '../config/types';
import carouselStyle from '../scss/carousel.scss'; import carouselStyle from '../scss/carousel.scss';
import { TransitionEffect } from '../types';
import { import {
CarouselController, CarouselController,
CarouselDirection, CarouselDirection,
+47
View File
@@ -0,0 +1,47 @@
import { HomeAssistant } from 'custom-card-helpers';
import { CSSResultGroup, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { RawFrigateCardConfig } from '../config/types';
import { localize } from '../localize/localize';
import basicBlockStyle from '../scss/basic-block.scss';
import { Diagnostics, getDiagnostics } from '../utils/diagnostics';
import { renderMessage } from './message';
@customElement('frigate-card-diagnostics')
export class FrigateCardDiagnostics extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public rawConfig?: RawFrigateCardConfig;
@state()
protected _diagnostics: Diagnostics | null = null;
protected async _fetchDiagnostics(): Promise<void> {
this._diagnostics = await getDiagnostics(this.hass, this.rawConfig);
}
protected render(): TemplateResult | void {
if (!this._diagnostics) {
this._fetchDiagnostics().then(() => this.requestUpdate());
return;
}
return renderMessage({
message: localize('error.diagnostics'),
type: 'diagnostics',
icon: 'mdi:information',
context: this._diagnostics,
});
}
static get styles(): CSSResultGroup {
return unsafeCSS(basicBlockStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-diagnostics': FrigateCardDiagnostics;
}
}
+20 -21
View File
@@ -1,39 +1,42 @@
import { HASSDomEvent, HomeAssistant } from 'custom-card-helpers'; import { HASSDomEvent, HomeAssistant } from 'custom-card-helpers';
import { import {
CSSResultGroup, CSSResultGroup,
html,
LitElement, LitElement,
PropertyValues, PropertyValues,
TemplateResult, TemplateResult,
html,
unsafeCSS, unsafeCSS,
} from 'lit'; } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import { localize } from '../localize/localize.js'; import { classMap } from 'lit/directives/class-map.js';
import elementsStyle from '../scss/elements.scss'; import { actionHandler } from '../action-handler-directive.js';
import ptzStyle from '../scss/elements-ptz.scss';
import { import {
Actions, Actions,
ActionsConfig, ActionsConfig,
FrigateCardError,
FrigateCardPTZConfig, FrigateCardPTZConfig,
FrigateConditional, FrigateConditional,
MenuButton,
MenuIcon, MenuIcon,
MenuItem,
MenuStateIcon, MenuStateIcon,
MenuSubmenu, MenuSubmenu,
MenuSubmenuSelect, MenuSubmenuSelect,
PictureElements, PictureElements,
} from '../types.js'; } from '../config/types.js';
import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { localize } from '../localize/localize.js';
import { dispatchFrigateCardErrorEvent } from './message.js'; import ptzStyle from '../scss/elements-ptz.scss';
import { actionHandler } from '../action-handler-directive.js'; import elementsStyle from '../scss/elements.scss';
import { FrigateCardError } from '../types.js';
import { import {
frigateCardHandleActionConfig, frigateCardHandleActionConfig,
frigateCardHasAction, frigateCardHasAction,
getActionConfigGivenAction, getActionConfigGivenAction,
} from '../utils/action.js'; } from '../utils/action.js';
import { classMap } from 'lit/directives/class-map.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js';
import { ConditionControllerEpoch, evaluateConditionViaEvent } from '../conditions.js'; import {
ConditionsManagerEpoch,
evaluateConditionViaEvent,
} from '../card-controller/conditions-manager.js';
import { dispatchFrigateCardErrorEvent } from './message.js';
/* A note on picture element rendering: /* A note on picture element rendering:
* *
@@ -85,7 +88,7 @@ export class FrigateCardElementsCore extends LitElement {
* property even though it is not currently directly used by this class. * property even though it is not currently directly used by this class.
*/ */
@property({ attribute: false }) @property({ attribute: false })
public conditionControllerEpoch?: ConditionControllerEpoch; public conditionsManagerEpoch?: ConditionsManagerEpoch;
protected _root: HuiConditionalElement | null = null; protected _root: HuiConditionalElement | null = null;
@@ -167,7 +170,7 @@ export class FrigateCardElements extends LitElement {
public hass?: HomeAssistant; public hass?: HomeAssistant;
@property({ attribute: false }) @property({ attribute: false })
public conditionControllerEpoch?: ConditionControllerEpoch; public conditionsManagerEpoch?: ConditionsManagerEpoch;
@property({ attribute: false }) @property({ attribute: false })
public elements: PictureElements; public elements: PictureElements;
@@ -181,11 +184,7 @@ export class FrigateCardElements extends LitElement {
protected _menuRemoveHandler(ev: Event): void { protected _menuRemoveHandler(ev: Event): void {
// Re-dispatch event from this element (instead of the disconnected one, as // Re-dispatch event from this element (instead of the disconnected one, as
// there is no parent of the disconnected element). // there is no parent of the disconnected element).
dispatchFrigateCardEvent<MenuButton>( dispatchFrigateCardEvent<MenuItem>(this, 'menu-remove', (ev as CustomEvent).detail);
this,
'menu-remove',
(ev as CustomEvent).detail,
);
} }
/** /**
@@ -193,7 +192,7 @@ export class FrigateCardElements extends LitElement {
* @param ev The event. * @param ev The event.
*/ */
protected _menuAddHandler(ev: Event): void { protected _menuAddHandler(ev: Event): void {
ev = ev as CustomEvent<MenuButton>; ev = ev as CustomEvent<MenuItem>;
const path = ev.composedPath(); const path = ev.composedPath();
if (!path.length) { if (!path.length) {
return; return;
@@ -226,7 +225,7 @@ export class FrigateCardElements extends LitElement {
protected render(): TemplateResult { protected render(): TemplateResult {
return html`<frigate-card-elements-core return html`<frigate-card-elements-core
.hass=${this.hass} .hass=${this.hass}
.conditionControllerEpoch=${this.conditionControllerEpoch} .conditionsManagerEpoch=${this.conditionsManagerEpoch}
.elements=${this.elements} .elements=${this.elements}
> >
</frigate-card-elements-core>`; </frigate-card-elements-core>`;
+17 -20
View File
@@ -7,37 +7,37 @@ import {
unsafeCSS, unsafeCSS,
} from 'lit'; } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import galleryStyle from '../scss/gallery.scss'; import { classMap } from 'lit/directives/class-map.js';
import galleryCoreStyle from '../scss/gallery-core.scss'; import { createRef, ref, Ref } from 'lit/directives/ref.js';
import throttle from 'lodash-es/throttle';
import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js';
import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types';
import { import {
CardWideConfig, CardWideConfig,
ExtendedHomeAssistant,
frigateCardConfigDefaults, frigateCardConfigDefaults,
GalleryConfig, GalleryConfig,
THUMBNAIL_WIDTH_MAX, THUMBNAIL_WIDTH_MAX,
} from '../types.js'; } from '../config/types';
import { localize } from '../localize/localize';
import galleryCoreStyle from '../scss/gallery-core.scss';
import galleryStyle from '../scss/gallery.scss';
import { ExtendedHomeAssistant } from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { errorToConsole, sleep } from '../utils/basic';
import { import {
changeViewToRecentEventsForCameraAndDependents, changeViewToRecentEventsForCameraAndDependents,
changeViewToRecentRecordingForCameraAndDependents, changeViewToRecentRecordingForCameraAndDependents,
} from '../utils/media-to-view.js'; } from '../utils/media-to-view.js';
import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js'; import { ViewMedia } from '../view/media';
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
import { MediaQueriesResults } from '../view/media-queries-results';
import { View } from '../view/view.js'; import { View } from '../view/view.js';
import './media-filter';
import { renderMessage, renderProgressIndicator } from './message.js'; import { renderMessage, renderProgressIndicator } from './message.js';
import './surround-basic';
import './thumbnail.js'; import './thumbnail.js';
import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types';
import { MediaQueriesResults } from '../view/media-queries-results';
import { errorToConsole, sleep } from '../utils/basic';
import './media-filter';
import './surround-basic';
import { ViewMedia } from '../view/media';
import { localize } from '../localize/localize';
import throttle from 'lodash-es/throttle';
import { classMap } from 'lit/directives/class-map.js';
const GALLERY_MEDIA_FILTER_MENU_ICONS = { const GALLERY_MEDIA_FILTER_MENU_ICONS = {
closed: 'mdi:filter-cog-outline', closed: 'mdi:filter-cog-outline',
@@ -82,7 +82,6 @@ export class FrigateCardGallery extends LitElement {
if (this.view.is('recordings')) { if (this.view.is('recordings')) {
changeViewToRecentRecordingForCameraAndDependents( changeViewToRecentRecordingForCameraAndDependents(
this, this,
this.hass,
this.cameraManager, this.cameraManager,
this.cardWideConfig, this.cardWideConfig,
this.view, this.view,
@@ -95,7 +94,6 @@ export class FrigateCardGallery extends LitElement {
: null; : null;
changeViewToRecentEventsForCameraAndDependents( changeViewToRecentEventsForCameraAndDependents(
this, this,
this.hass,
this.cameraManager, this.cameraManager,
this.cardWideConfig, this.cardWideConfig,
this.view, this.view,
@@ -344,7 +342,6 @@ export class FrigateCardGalleryCore extends LitElement {
let extension: ExtendedMediaQueryResult<MediaQuery> | null; let extension: ExtendedMediaQueryResult<MediaQuery> | null;
try { try {
extension = await this.cameraManager.extendMediaQueries<MediaQuery>( extension = await this.cameraManager.extendMediaQueries<MediaQuery>(
this.hass,
rawQueries, rawQueries,
existingMedia, existingMedia,
direction, direction,
+4 -8
View File
@@ -6,29 +6,25 @@ import {
LitElement, LitElement,
PropertyValues, PropertyValues,
TemplateResult, TemplateResult,
unsafeCSS unsafeCSS,
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { live } from 'lit/directives/live.js'; import { live } from 'lit/directives/live.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js';
import isEqual from 'lodash-es/isEqual'; import isEqual from 'lodash-es/isEqual';
import { CachedValueController } from '../cached-value-controller.js'; import { CachedValueController } from '../cached-value-controller.js';
import { CameraConfig, ImageViewConfig } from '../config/types.js';
import defaultImage from '../images/frigate-bird-in-sky.jpg'; import defaultImage from '../images/frigate-bird-in-sky.jpg';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
import imageStyle from '../scss/image.scss'; import imageStyle from '../scss/image.scss';
import { import { FrigateCardMediaPlayer, MediaLoadedInfo } from '../types.js';
CameraConfig,
FrigateCardMediaPlayer,
ImageViewConfig,
MediaLoadedInfo
} from '../types.js';
import { contentsChanged } from '../utils/basic.js'; import { contentsChanged } from '../utils/basic.js';
import { isHassDifferent } from '../utils/ha'; import { isHassDifferent } from '../utils/ha';
import { import {
createMediaLoadedInfo, createMediaLoadedInfo,
dispatchExistingMediaLoadedInfoAsEvent, dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaPauseEvent, dispatchMediaPauseEvent,
dispatchMediaPlayEvent dispatchMediaPlayEvent,
} from '../utils/media-info.js'; } from '../utils/media-info.js';
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
import { View } from '../view/view.js'; import { View } from '../view/view.js';
+2 -6
View File
@@ -8,14 +8,10 @@ import {
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { CameraEndpoints } from '../../camera-manager/types.js'; import { CameraEndpoints } from '../../camera-manager/types.js';
import { CameraConfig, MicrophoneConfig } from '../../config/types.js';
import { localize } from '../../localize/localize'; import { localize } from '../../localize/localize';
import liveMSEStyle from '../../scss/live-go2rtc.scss'; import liveMSEStyle from '../../scss/live-go2rtc.scss';
import { import { ExtendedHomeAssistant, FrigateCardMediaPlayer } from '../../types.js';
CameraConfig,
ExtendedHomeAssistant,
FrigateCardMediaPlayer,
MicrophoneConfig,
} from '../../types.js';
import { getEndpointAddressOrDispatchError } from '../../utils/endpoint'; import { getEndpointAddressOrDispatchError } from '../../utils/endpoint';
import { setControlsOnVideo } from '../../utils/media.js'; import { setControlsOnVideo } from '../../utils/media.js';
import { screenshotMedia } from '../../utils/screenshot.js'; import { screenshotMedia } from '../../utils/screenshot.js';
+5 -4
View File
@@ -2,12 +2,13 @@ import { HomeAssistant } from 'custom-card-helpers';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js';
import liveHAStyle from '../../scss/live-ha.scss'; import { CameraConfig } from '../../config/types';
import { CameraConfig, FrigateCardMediaPlayer } from '../../types.js';
import { getStateObjOrDispatchError } from './live.js';
import '../../patches/ha-camera-stream'; import '../../patches/ha-camera-stream';
import '../../patches/ha-hls-player.js'; import '../../patches/ha-hls-player.js';
import '../../patches/ha-web-rtc-player.ts'; import '../../patches/ha-web-rtc-player.ts';
import liveHAStyle from '../../scss/live-ha.scss';
import { FrigateCardMediaPlayer } from '../../types.js';
import { getStateObjOrDispatchError } from './live.js';
@customElement('frigate-card-live-ha') @customElement('frigate-card-live-ha')
export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPlayer { export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPlayer {
@@ -55,7 +56,7 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla
} }
public async getScreenshotURL(): Promise<string | null> { public async getScreenshotURL(): Promise<string | null> {
return await this._playerRef.value?.getScreenshotURL() ?? null; return (await this._playerRef.value?.getScreenshotURL()) ?? null;
} }
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
+2 -1
View File
@@ -2,8 +2,9 @@ import { HomeAssistant } from 'custom-card-helpers';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { CameraConfig } from '../../config/types';
import basicBlockStyle from '../../scss/basic-block.scss'; import basicBlockStyle from '../../scss/basic-block.scss';
import { CameraConfig, FrigateCardMediaPlayer } from '../../types.js'; import { FrigateCardMediaPlayer } from '../../types.js';
import '../image.js'; import '../image.js';
import { getStateObjOrDispatchError } from './live.js'; import { getStateObjOrDispatchError } from './live.js';
+3 -7
View File
@@ -4,22 +4,18 @@ import { customElement, property } from 'lit/decorators.js';
import { until } from 'lit/directives/until.js'; import { until } from 'lit/directives/until.js';
import { CameraEndpoints } from '../../camera-manager/types.js'; import { CameraEndpoints } from '../../camera-manager/types.js';
import { renderProgressIndicator } from '../../components/message.js'; import { renderProgressIndicator } from '../../components/message.js';
import { CameraConfig, CardWideConfig } from '../../config/types.js';
import { localize } from '../../localize/localize.js'; import { localize } from '../../localize/localize.js';
import liveJSMPEGStyle from '../../scss/live-jsmpeg.scss'; import liveJSMPEGStyle from '../../scss/live-jsmpeg.scss';
import { import { ExtendedHomeAssistant, FrigateCardMediaPlayer } from '../../types.js';
CameraConfig,
CardWideConfig,
ExtendedHomeAssistant,
FrigateCardMediaPlayer,
} from '../../types.js';
import { getEndpointAddressOrDispatchError } from '../../utils/endpoint.js'; import { getEndpointAddressOrDispatchError } from '../../utils/endpoint.js';
import { import {
dispatchMediaLoadedEvent, dispatchMediaLoadedEvent,
dispatchMediaPauseEvent, dispatchMediaPauseEvent,
dispatchMediaPlayEvent, dispatchMediaPlayEvent,
} from '../../utils/media-info.js'; } from '../../utils/media-info.js';
import { dispatchErrorMessageEvent } from '../message.js';
import { Timer } from '../../utils/timer.js'; import { Timer } from '../../utils/timer.js';
import { dispatchErrorMessageEvent } from '../message.js';
// Number of seconds a signed URL is valid for. // Number of seconds a signed URL is valid for.
const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
+2 -6
View File
@@ -3,14 +3,10 @@ import { HomeAssistant } from 'custom-card-helpers';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { CameraEndpoints } from '../../camera-manager/types.js'; import { CameraEndpoints } from '../../camera-manager/types.js';
import { CameraConfig, CardWideConfig } from '../../config/types.js';
import { localize } from '../../localize/localize.js'; import { localize } from '../../localize/localize.js';
import liveWebRTCCardStyle from '../../scss/live-webrtc-card.scss'; import liveWebRTCCardStyle from '../../scss/live-webrtc-card.scss';
import { import { FrigateCardError, FrigateCardMediaPlayer } from '../../types.js';
CameraConfig,
CardWideConfig,
FrigateCardError,
FrigateCardMediaPlayer,
} from '../../types.js';
import { mayHaveAudio } from '../../utils/audio.js'; import { mayHaveAudio } from '../../utils/audio.js';
import { import {
dispatchMediaLoadedEvent, dispatchMediaLoadedEvent,
+24 -20
View File
@@ -16,26 +16,31 @@ import { keyed } from 'lit/directives/keyed.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { CameraManager } from '../../camera-manager/manager.js'; import { CameraManager } from '../../camera-manager/manager.js';
import { CameraConfigs, CameraEndpoints } from '../../camera-manager/types.js'; import { CameraConfigs, CameraEndpoints } from '../../camera-manager/types.js';
import { ConditionControllerEpoch, getOverriddenConfig } from '../../conditions.js'; import {
CameraConfig,
CardWideConfig,
frigateCardConfigDefaults,
LiveConfig,
LiveOverrides,
LiveProvider,
TransitionEffect,
} from '../../config/types.js';
import { localize } from '../../localize/localize.js'; import { localize } from '../../localize/localize.js';
import basicBlockStyle from '../../scss/basic-block.scss'; import basicBlockStyle from '../../scss/basic-block.scss';
import liveCarouselStyle from '../../scss/live-carousel.scss'; import liveCarouselStyle from '../../scss/live-carousel.scss';
import liveProviderStyle from '../../scss/live-provider.scss'; import liveProviderStyle from '../../scss/live-provider.scss';
import { import {
CameraConfig,
CardWideConfig,
ExtendedHomeAssistant, ExtendedHomeAssistant,
frigateCardConfigDefaults,
FrigateCardMediaPlayer, FrigateCardMediaPlayer,
LiveConfig,
LiveOverrides,
LiveProvider,
MediaLoadedInfo, MediaLoadedInfo,
Message, Message,
TransitionEffect,
} from '../../types.js'; } from '../../types.js';
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
import { contentsChanged } from '../../utils/basic.js'; import { contentsChanged } from '../../utils/basic.js';
import {
ConditionsManagerEpoch,
getOverriddenConfig,
} from '../../card-controller/conditions-manager.js';
import { CarouselSelected } from '../../utils/embla/carousel-controller.js'; import { CarouselSelected } from '../../utils/embla/carousel-controller.js';
import { AutoLazyLoad } from '../../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js'; import { AutoLazyLoad } from '../../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js';
import { AutoMediaActions } from '../../utils/embla/plugins/auto-media-actions/auto-media-actions.js'; import { AutoMediaActions } from '../../utils/embla/plugins/auto-media-actions/auto-media-actions.js';
@@ -121,7 +126,7 @@ export const getStateObjOrDispatchError = (
@customElement('frigate-card-live') @customElement('frigate-card-live')
export class FrigateCardLive extends LitElement { export class FrigateCardLive extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public conditionControllerEpoch?: ConditionControllerEpoch; public conditionsManagerEpoch?: ConditionsManagerEpoch;
@property({ attribute: false }) @property({ attribute: false })
public hass?: ExtendedHomeAssistant; public hass?: ExtendedHomeAssistant;
@@ -247,7 +252,7 @@ export class FrigateCardLive extends LitElement {
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig} .nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
.overriddenLiveConfig=${this.overriddenLiveConfig} .overriddenLiveConfig=${this.overriddenLiveConfig}
.inBackground=${this._inBackground} .inBackground=${this._inBackground}
.conditionControllerEpoch=${this.conditionControllerEpoch} .conditionsManagerEpoch=${this.conditionsManagerEpoch}
.liveOverrides=${this.liveOverrides} .liveOverrides=${this.liveOverrides}
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${this.cardWideConfig}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
@@ -305,7 +310,7 @@ export class FrigateCardLiveGrid extends LitElement {
public liveOverrides?: LiveOverrides; public liveOverrides?: LiveOverrides;
@property({ attribute: false }) @property({ attribute: false })
public conditionControllerEpoch?: ConditionControllerEpoch; public conditionsManagerEpoch?: ConditionsManagerEpoch;
@property({ attribute: false }) @property({ attribute: false })
public cardWideConfig?: CardWideConfig; public cardWideConfig?: CardWideConfig;
@@ -325,7 +330,7 @@ export class FrigateCardLiveGrid extends LitElement {
.viewFilterCameraID=${cameraID} .viewFilterCameraID=${cameraID}
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig} .nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
.overriddenLiveConfig=${this.overriddenLiveConfig} .overriddenLiveConfig=${this.overriddenLiveConfig}
.conditionControllerEpoch=${this.conditionControllerEpoch} .conditionsManagerEpoch=${this.conditionsManagerEpoch}
.liveOverrides=${this.liveOverrides} .liveOverrides=${this.liveOverrides}
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${this.cardWideConfig}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
@@ -360,7 +365,7 @@ export class FrigateCardLiveGrid extends LitElement {
} }
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.conditionControllerEpoch || !this.nonOverriddenLiveConfig) { if (!this.conditionsManagerEpoch || !this.nonOverriddenLiveConfig) {
return; return;
} }
const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs(); const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
@@ -406,7 +411,7 @@ export class FrigateCardLiveCarousel extends LitElement {
public liveOverrides?: LiveOverrides; public liveOverrides?: LiveOverrides;
@property({ attribute: false }) @property({ attribute: false })
public conditionControllerEpoch?: ConditionControllerEpoch; public conditionsManagerEpoch?: ConditionsManagerEpoch;
@property({ attribute: false }) @property({ attribute: false })
public cardWideConfig?: CardWideConfig; public cardWideConfig?: CardWideConfig;
@@ -572,7 +577,7 @@ export class FrigateCardLiveCarousel extends LitElement {
!this.nonOverriddenLiveConfig || !this.nonOverriddenLiveConfig ||
!this.hass || !this.hass ||
!this.cameraManager || !this.cameraManager ||
!this.conditionControllerEpoch !this.conditionsManagerEpoch
) { ) {
return; return;
} }
@@ -581,13 +586,13 @@ export class FrigateCardLiveCarousel extends LitElement {
// <frigate-card-live-provider> is rendering right now, so we provide a // <frigate-card-live-provider> is rendering right now, so we provide a
// stateOverride to evaluate the condition in that context. // stateOverride to evaluate the condition in that context.
const config = getOverriddenConfig( const config = getOverriddenConfig(
this.conditionControllerEpoch.controller, this.conditionsManagerEpoch.manager,
this.nonOverriddenLiveConfig, this.nonOverriddenLiveConfig,
this.liveOverrides, this.liveOverrides,
{ camera: cameraID }, { camera: cameraID },
) as LiveConfig; ) as LiveConfig;
const cameraMetadata = this.cameraManager.getCameraMetadata(this.hass, cameraID); const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID);
return html` return html`
<div class="embla__slide"> <div class="embla__slide">
@@ -650,14 +655,13 @@ export class FrigateCardLiveCarousel extends LitElement {
}; };
const cameraMetadataPrevious = prevID const cameraMetadataPrevious = prevID
? this.cameraManager.getCameraMetadata(this.hass, overrideCameraID(prevID)) ? this.cameraManager.getCameraMetadata(overrideCameraID(prevID))
: null; : null;
const cameraMetadataCurrent = this.cameraManager.getCameraMetadata( const cameraMetadataCurrent = this.cameraManager.getCameraMetadata(
this.hass,
overrideCameraID(this.viewFilterCameraID ?? this.view.camera), overrideCameraID(this.viewFilterCameraID ?? this.view.camera),
); );
const cameraMetadataNext = nextID const cameraMetadataNext = nextID
? this.cameraManager.getCameraMetadata(this.hass, overrideCameraID(nextID)) ? this.cameraManager.getCameraMetadata(overrideCameraID(nextID))
: null; : null;
const titleConfig = getDefaultTitleConfigForView( const titleConfig = getDefaultTitleConfigForView(
+40 -49
View File
@@ -1,3 +1,15 @@
import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin';
import { HomeAssistant } from 'custom-card-helpers';
import endOfDay from 'date-fns/endOfDay';
import endOfMonth from 'date-fns/endOfMonth';
import endOfYesterday from 'date-fns/endOfYesterday';
import endOfToday from 'date-fns/esm/endOfToday';
import startOfToday from 'date-fns/esm/startOfToday';
import format from 'date-fns/format';
import parse from 'date-fns/parse';
import startOfDay from 'date-fns/startOfDay';
import startOfYesterday from 'date-fns/startOfYesterday';
import sub from 'date-fns/sub';
import { import {
CSSResultGroup, CSSResultGroup,
html, html,
@@ -10,35 +22,23 @@ import {
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js';
import isEqual from 'lodash-es/isEqual';
import orderBy from 'lodash-es/orderBy';
import uniqWith from 'lodash-es/uniqWith';
import { CameraManager } from '../camera-manager/manager';
import { DateRange } from '../camera-manager/range'; import { DateRange } from '../camera-manager/range';
import { DataQuery, MediaMetadata, QueryType } from '../camera-manager/types';
import { CardWideConfig } from '../config/types';
import { localize } from '../localize/localize'; import { localize } from '../localize/localize';
import mediaFilterStyle from '../scss/media-filter.scss'; import mediaFilterStyle from '../scss/media-filter.scss';
import { executeMediaQueryForView } from '../utils/media-to-view.js';
import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic'; import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic';
import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin'; import { executeMediaQueryForViewWithErrorDispatching } from '../utils/media-to-view.js';
import './select'; import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
import { FrigateCardSelect, SelectOption, SelectValues } from './select';
import uniqWith from 'lodash-es/uniqWith';
import sub from 'date-fns/sub';
import endOfDay from 'date-fns/endOfDay';
import endOfYesterday from 'date-fns/endOfYesterday';
import endOfToday from 'date-fns/esm/endOfToday';
import startOfToday from 'date-fns/esm/startOfToday';
import startOfDay from 'date-fns/startOfDay';
import startOfYesterday from 'date-fns/startOfYesterday';
import parse from 'date-fns/parse';
import { MediaQueriesClassifier } from '../view/media-queries-classifier'; import { MediaQueriesClassifier } from '../view/media-queries-classifier';
import { View } from '../view/view'; import { View } from '../view/view';
import { CameraManager } from '../camera-manager/manager'; import './select';
import { HomeAssistant } from 'custom-card-helpers'; import { FrigateCardSelect, SelectOption, SelectValues } from './select';
import { DataQuery, MediaMetadata, QueryType } from '../camera-manager/types';
import format from 'date-fns/format';
import endOfMonth from 'date-fns/endOfMonth';
import isEqual from 'lodash-es/isEqual';
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
import './select.js'; import './select.js';
import orderBy from 'lodash-es/orderBy';
import { CardWideConfig } from '../types';
interface MediaFilterCoreDefaults { interface MediaFilterCoreDefaults {
cameraIDs?: string[]; cameraIDs?: string[];
@@ -228,9 +228,8 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
]); ]);
( (
await executeMediaQueryForView( await executeMediaQueryForViewWithErrorDispatching(
this, this,
this.hass,
this.cameraManager, this.cameraManager,
this.view, this.view,
queries, queries,
@@ -252,9 +251,8 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
]); ]);
( (
await executeMediaQueryForView( await executeMediaQueryForViewWithErrorDispatching(
this, this,
this.hass,
this.cameraManager, this.cameraManager,
this.view, this.view,
queries, queries,
@@ -275,7 +273,7 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
this._cameraOptions = Array.from(cameras.keys()).map((cameraID) => ({ this._cameraOptions = Array.from(cameras.keys()).map((cameraID) => ({
value: cameraID, value: cameraID,
label: this.hass label: this.hass
? this.cameraManager?.getCameraMetadata(this.hass, cameraID)?.title ?? '' ? this.cameraManager?.getCameraMetadata(cameraID)?.title ?? ''
: '', : '',
})); }));
} }
@@ -284,7 +282,6 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
if (changedProps.has('cameraManager') && this.hass && this.cameraManager) { if (changedProps.has('cameraManager') && this.hass && this.cameraManager) {
this._mediaMetadataController = new MediaMetadataController( this._mediaMetadataController = new MediaMetadataController(
this, this,
this.hass,
this.cameraManager, this.cameraManager,
); );
} }
@@ -407,7 +404,7 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
...(what && { what: what }), ...(what && { what: what }),
...(where && { where: where }), ...(where && { where: where }),
...(favorite !== undefined && { favorite: favorite }), ...(favorite !== undefined && { favorite: favorite }),
...(tags && { tags: tags }) ...(tags && { tags: tags }),
}; };
} }
@@ -475,18 +472,18 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
</frigate-card-select>` </frigate-card-select>`
: ''} : ''}
${areEvents && this._mediaMetadataController.tagsOptions.length ${areEvents && this._mediaMetadataController.tagsOptions.length
? html` <frigate-card-select ? html` <frigate-card-select
${ref(this._refTags)} ${ref(this._refTags)}
label=${localize('media_filter.tag')} label=${localize('media_filter.tag')}
placeholder=${localize('media_filter.select_tag')} placeholder=${localize('media_filter.select_tag')}
clearable clearable
multiple multiple
.options=${this._mediaMetadataController.tagsOptions} .options=${this._mediaMetadataController.tagsOptions}
.value=${this._defaults?.tags} .value=${this._defaults?.tags}
@frigate-card:select:change=${this._valueChangedHandler.bind(this)} @frigate-card:select:change=${this._valueChangedHandler.bind(this)}
> >
</frigate-card-select>` </frigate-card-select>`
: ''} : ''}
${areEvents && this._mediaMetadataController.whereOptions.length ${areEvents && this._mediaMetadataController.whereOptions.length
? html` <frigate-card-select ? html` <frigate-card-select
${ref(this._refWhere)} ${ref(this._refWhere)}
@@ -523,7 +520,6 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
export class MediaMetadataController implements ReactiveController { export class MediaMetadataController implements ReactiveController {
protected _host: ReactiveControllerHost; protected _host: ReactiveControllerHost;
protected _hass: HomeAssistant;
protected _cameraManager: CameraManager; protected _cameraManager: CameraManager;
public tagsOptions: SelectOption[] = []; public tagsOptions: SelectOption[] = [];
@@ -531,13 +527,8 @@ export class MediaMetadataController implements ReactiveController {
public whatOptions: SelectOption[] = []; public whatOptions: SelectOption[] = [];
public whereOptions: SelectOption[] = []; public whereOptions: SelectOption[] = [];
constructor( constructor(host: ReactiveControllerHost, cameraManager: CameraManager) {
host: ReactiveControllerHost,
hass: HomeAssistant,
cameraManager: CameraManager,
) {
this._host = host; this._host = host;
this._hass = hass;
this._cameraManager = cameraManager; this._cameraManager = cameraManager;
host.addController(this); host.addController(this);
} }
@@ -549,7 +540,7 @@ export class MediaMetadataController implements ReactiveController {
async hostConnected() { async hostConnected() {
let metadata: MediaMetadata | null; let metadata: MediaMetadata | null;
try { try {
metadata = await this._cameraManager.getMediaMetadata(this._hass); metadata = await this._cameraManager.getMediaMetadata();
} catch (e) { } catch (e) {
errorToConsole(e as Error); errorToConsole(e as Error);
return; return;
+1 -1
View File
@@ -8,8 +8,8 @@ import {
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { ViewDisplayConfig } from '../config/types';
import mediaGridStyle from '../scss/media-grid.scss'; import mediaGridStyle from '../scss/media-grid.scss';
import { ViewDisplayConfig } from '../types.js';
import { MediaGridController } from '../utils/media-grid-controller.js'; import { MediaGridController } from '../utils/media-grid-controller.js';
@customElement('frigate-card-media-grid') @customElement('frigate-card-media-grid')
+10 -11
View File
@@ -1,10 +1,10 @@
import { HASSDomEvent, HomeAssistant } from 'custom-card-helpers'; import { HASSDomEvent, HomeAssistant } from 'custom-card-helpers';
import { import {
CSSResultGroup, CSSResultGroup,
html,
LitElement, LitElement,
PropertyValues, PropertyValues,
TemplateResult, TemplateResult,
html,
unsafeCSS, unsafeCSS,
} from 'lit'; } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
@@ -12,26 +12,25 @@ import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js'; import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js'; import { styleMap } from 'lit/directives/style-map.js';
import { actionHandler } from '../action-handler-directive.js'; import { actionHandler } from '../action-handler-directive.js';
import menuStyle from '../scss/menu.scss'; import { FRIGATE_ICON_SVG_PATH } from '../camera-manager/frigate/icon.js';
import type { import type {
ActionsConfig,
ActionType, ActionType,
MenuButton, ActionsConfig,
MenuConfig, MenuConfig,
MenuItem, MenuItem,
StateParameters, } from '../config/types.js';
} from '../types.js'; import { FRIGATE_BUTTON_MENU_ICON } from '../const.js';
import menuStyle from '../scss/menu.scss';
import type { StateParameters } from '../types.js';
import { import {
convertActionToFrigateCardCustomAction, convertActionToFrigateCardCustomAction,
frigateCardHandleActionConfig, frigateCardHandleActionConfig,
frigateCardHasAction, frigateCardHasAction,
getActionConfigGivenAction, getActionConfigGivenAction,
} from '../utils/action.js'; } from '../utils/action.js';
import { FRIGATE_ICON_SVG_PATH } from '../camera-manager/frigate/icon.js';
import { refreshDynamicStateParameters } from '../utils/ha'; import { refreshDynamicStateParameters } from '../utils/ha';
import './submenu.js';
import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js'; import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js';
import { FRIGATE_BUTTON_MENU_ICON } from '../const.js'; import './submenu.js';
/** /**
* A menu for the FrigateCard. * A menu for the FrigateCard.
@@ -62,7 +61,7 @@ export class FrigateCardMenu extends LitElement {
protected _menuConfig?: MenuConfig; protected _menuConfig?: MenuConfig;
@property({ attribute: false }) @property({ attribute: false })
public buttons: MenuButton[] = []; public buttons: MenuItem[] = [];
@property({ attribute: false }) @property({ attribute: false })
public entityRegistryManager?: EntityRegistryManager; public entityRegistryManager?: EntityRegistryManager;
@@ -228,7 +227,7 @@ export class FrigateCardMenu extends LitElement {
* @param button The button configuration to render. * @param button The button configuration to render.
* @returns A rendered template or void. * @returns A rendered template or void.
*/ */
protected _renderButton(button: MenuButton): TemplateResult | void { protected _renderButton(button: MenuItem): TemplateResult | void {
if (button.type === 'custom:frigate-card-menu-submenu') { if (button.type === 'custom:frigate-card-menu-submenu') {
return html` <frigate-card-submenu return html` <frigate-card-submenu
.hass=${this.hass} .hass=${this.hass}
+6 -5
View File
@@ -2,10 +2,11 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { ClassInfo, classMap } from 'lit/directives/class-map.js'; import { ClassInfo, classMap } from 'lit/directives/class-map.js';
import { ref, Ref } from 'lit/directives/ref.js'; import { ref, Ref } from 'lit/directives/ref.js';
import { CardWideConfig } from '../config/types.js';
import { TROUBLESHOOTING_URL } from '../const.js'; import { TROUBLESHOOTING_URL } from '../const.js';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
import messageStyle from '../scss/message.scss'; import messageStyle from '../scss/message.scss';
import { CardWideConfig, FrigateCardError, Message, MessageType } from '../types.js'; import { FrigateCardError, Message, MessageType } from '../types.js';
import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js';
@customElement('frigate-card-message') @customElement('frigate-card-message')
@@ -106,12 +107,12 @@ export class FrigateCardProgressIndicator extends LitElement {
} }
} }
export function renderMessage(message: Message): TemplateResult { export function renderMessage(message: Message | null): TemplateResult {
if (message.type === 'error') { if (message?.type === 'error') {
return html` <frigate-card-error-message return html` <frigate-card-error-message
.message=${message} .message=${message}
></frigate-card-error-message>`; ></frigate-card-error-message>`;
} else { } else if (message) {
return html` <frigate-card-message return html` <frigate-card-message
.message=${message.message} .message=${message.message}
.icon=${message.icon} .icon=${message.icon}
@@ -124,7 +125,7 @@ export function renderMessage(message: Message): TemplateResult {
export function renderProgressIndicator(options?: { export function renderProgressIndicator(options?: {
message?: string; message?: string;
cardWideConfig?: CardWideConfig; cardWideConfig?: CardWideConfig | null;
componentRef?: Ref<HTMLElement>; componentRef?: Ref<HTMLElement>;
classes?: ClassInfo; classes?: ClassInfo;
size?: FrigateCardProgressIndicatorSize; size?: FrigateCardProgressIndicatorSize;
+3 -5
View File
@@ -1,13 +1,11 @@
import { HomeAssistant } from 'custom-card-helpers';
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { NextPreviousControlConfig } from '../config/types.js';
import { NextPreviousControlConfig } from '../types.js';
import controlStyle from '../scss/next-previous-control.scss'; import controlStyle from '../scss/next-previous-control.scss';
import { createFetchThumbnailTask } from '../utils/thumbnail.js';
import { HomeAssistant } from 'custom-card-helpers';
import { renderTask } from '../utils/task.js'; import { renderTask } from '../utils/task.js';
import { createFetchThumbnailTask } from '../utils/thumbnail.js';
@customElement('frigate-card-next-previous-control') @customElement('frigate-card-next-previous-control')
export class FrigateCardNextPreviousControl extends LitElement { export class FrigateCardNextPreviousControl extends LitElement {
+6 -8
View File
@@ -11,13 +11,9 @@ import { customElement, property, state } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js'; import { ifDefined } from 'lit/directives/if-defined.js';
import { StyleInfo, styleMap } from 'lit/directives/style-map.js'; import { StyleInfo, styleMap } from 'lit/directives/style-map.js';
import { actionHandler } from '../action-handler-directive.js'; import { actionHandler } from '../action-handler-directive.js';
import { MenuSubmenu, MenuSubmenuItem, MenuSubmenuSelect } from '../config/types.js';
import submenuStyle from '../scss/submenu.scss'; import submenuStyle from '../scss/submenu.scss';
import { import { StateParameters } from '../types.js';
MenuSubmenu,
MenuSubmenuItem,
MenuSubmenuSelect,
StateParameters,
} from '../types.js';
import { import {
frigateCardHasAction, frigateCardHasAction,
stopEventFromActivatingCardWideActions, stopEventFromActivatingCardWideActions,
@@ -39,7 +35,9 @@ export class FrigateCardSubmenu extends LitElement {
if (!this.hass) { if (!this.hass) {
return; return;
} }
const stateParameters = refreshDynamicStateParameters(this.hass, { ...item } as StateParameters); const stateParameters = refreshDynamicStateParameters(this.hass, {
...item,
} as StateParameters);
const getIcon = (stateParameters: StateParameters): TemplateResult => { const getIcon = (stateParameters: StateParameters): TemplateResult => {
if (stateParameters.icon) { if (stateParameters.icon) {
return html` <ha-icon return html` <ha-icon
@@ -95,7 +93,7 @@ export class FrigateCardSubmenu extends LitElement {
@click=${(ev) => stopEventFromActivatingCardWideActions(ev)} @click=${(ev) => stopEventFromActivatingCardWideActions(ev)}
> >
<ha-icon-button <ha-icon-button
style="${styleMap(this.submenu.style as StyleInfo || {})}" style="${styleMap((this.submenu.style as StyleInfo) || {})}"
class="button" class="button"
slot="trigger" slot="trigger"
.label=${this.submenu.title || ''} .label=${this.submenu.title || ''}
+3 -5
View File
@@ -8,14 +8,13 @@ import {
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { CameraManager } from '../camera-manager/manager.js'; import { CameraManager } from '../camera-manager/manager.js';
import basicBlockStyle from '../scss/basic-block.scss';
import { import {
CardWideConfig, CardWideConfig,
ClipsOrSnapshotsOrAll,
ExtendedHomeAssistant,
MiniTimelineControlConfig, MiniTimelineControlConfig,
ThumbnailsControlConfig, ThumbnailsControlConfig,
} from '../types.js'; } from '../config/types.js';
import basicBlockStyle from '../scss/basic-block.scss';
import { ClipsOrSnapshotsOrAll, ExtendedHomeAssistant } from '../types.js';
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
import { getAllDependentCameras } from '../utils/camera.js'; import { getAllDependentCameras } from '../utils/camera.js';
import { changeViewToRecentEventsForCameraAndDependents } from '../utils/media-to-view'; import { changeViewToRecentEventsForCameraAndDependents } from '../utils/media-to-view';
@@ -82,7 +81,6 @@ export class FrigateCardSurround extends LitElement {
} }
await changeViewToRecentEventsForCameraAndDependents( await changeViewToRecentEventsForCameraAndDependents(
this, this,
this.hass,
this.cameraManager, this.cameraManager,
this.cardWideConfig, this.cardWideConfig,
this.view, this.view,
+2 -1
View File
@@ -9,8 +9,9 @@ import {
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../camera-manager/manager.js'; import { CameraManager } from '../camera-manager/manager.js';
import { ThumbnailsControlConfig } from '../config/types.js';
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
import { ExtendedHomeAssistant, ThumbnailsControlConfig } from '../types.js'; import { ExtendedHomeAssistant } from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js';
import { CarouselDirection } from '../utils/embla/carousel-controller.js'; import { CarouselDirection } from '../utils/embla/carousel-controller.js';
+3 -3
View File
@@ -271,7 +271,9 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
const rawEndTime = this.media.getEndTime(); const rawEndTime = this.media.getEndTime();
const duration = const duration =
rawStartTime && rawEndTime ? getDurationString(rawStartTime, rawEndTime) : null; rawStartTime && rawEndTime ? getDurationString(rawStartTime, rawEndTime) : null;
const inProgress = this.media.inProgress() ? localize('recording.in_progress') : null; const inProgress = this.media.inProgress()
? localize('recording.in_progress')
: null;
const seek = this.seek ? format(this.seek, 'HH:mm:ss') : null; const seek = this.seek ? format(this.seek, 'HH:mm:ss') : null;
@@ -404,7 +406,6 @@ export class FrigateCardThumbnail extends LitElement {
mediaCapabilities?.canDownload; mediaCapabilities?.canDownload;
const cameraTitle = this.cameraManager.getCameraMetadata( const cameraTitle = this.cameraManager.getCameraMetadata(
this.hass,
this.media.getCameraID(), this.media.getCameraID(),
)?.title; )?.title;
@@ -434,7 +435,6 @@ export class FrigateCardThumbnail extends LitElement {
if (this.hass && this.media) { if (this.hass && this.media) {
try { try {
await this.cameraManager?.favoriteMedia( await this.cameraManager?.favoriteMedia(
this.hass,
this.media, this.media,
!this.media?.isFavorite(), !this.media?.isFavorite(),
); );
+15 -20
View File
@@ -30,17 +30,17 @@ import { CameraManager } from '../camera-manager/manager';
import { rangesOverlap } from '../camera-manager/range'; import { rangesOverlap } from '../camera-manager/range';
import { MediaQuery } from '../camera-manager/types'; import { MediaQuery } from '../camera-manager/types';
import { convertRangeToCacheFriendlyTimes } from '../camera-manager/util'; import { convertRangeToCacheFriendlyTimes } from '../camera-manager/util';
import { localize } from '../localize/localize';
import timelineCoreStyle from '../scss/timeline-core.scss';
import { import {
CameraConfig, CameraConfig,
CardWideConfig, CardWideConfig,
ExtendedHomeAssistant,
frigateCardConfigDefaults, frigateCardConfigDefaults,
FrigateCardView, FrigateCardView,
ThumbnailsControlBaseConfig, ThumbnailsControlBaseConfig,
TimelineCoreConfig, TimelineCoreConfig,
} from '../types'; } from '../config/types';
import { localize } from '../localize/localize';
import timelineCoreStyle from '../scss/timeline-core.scss';
import { ExtendedHomeAssistant } from '../types';
import { stopEventFromActivatingCardWideActions } from '../utils/action'; import { stopEventFromActivatingCardWideActions } from '../utils/action';
import { import {
contentsChanged, contentsChanged,
@@ -50,7 +50,10 @@ import {
isTruthy, isTruthy,
setOrRemoveAttribute, setOrRemoveAttribute,
} from '../utils/basic'; } from '../utils/basic';
import { executeMediaQueryForView, findBestMediaIndex } from '../utils/media-to-view'; import {
executeMediaQueryForViewWithErrorDispatching,
findBestMediaIndex,
} from '../utils/media-to-view';
import { FrigateCardTimelineItem, TimelineDataSource } from '../utils/timeline-source'; import { FrigateCardTimelineItem, TimelineDataSource } from '../utils/timeline-source';
import { ViewMedia } from '../view/media'; import { ViewMedia } from '../view/media';
import { ViewMediaClassifier } from '../view/media-classifier'; import { ViewMediaClassifier } from '../view/media-classifier';
@@ -580,9 +583,8 @@ export class FrigateCardTimelineCore extends LitElement {
) { ) {
const query = this._createMediaQueries('recording'); const query = this._createMediaQueries('recording');
if (query) { if (query) {
view = await executeMediaQueryForView( view = await executeMediaQueryForViewWithErrorDispatching(
this, this,
this.hass,
this.cameraManager, this.cameraManager,
this.view, this.view,
query, query,
@@ -675,11 +677,11 @@ export class FrigateCardTimelineCore extends LitElement {
} }
this._removeTargetBar(); this._removeTargetBar();
if (!this.hass || !this._timeline || !this.view) { if (!this._timeline || !this.view) {
return; return;
} }
await this._timelineSource?.refresh(this.hass, this._getPrefetchWindow(properties)); await this._timelineSource?.refresh(this._getPrefetchWindow(properties));
const queryType = MediaQueriesClassifier.getQueriesType(this.view.query); const queryType = MediaQueriesClassifier.getQueriesType(this.view.query);
if (!queryType) { if (!queryType) {
@@ -735,9 +737,8 @@ export class FrigateCardTimelineCore extends LitElement {
if (!this.hass || !this.cameraManager || !this.view || !query) { if (!this.hass || !this.cameraManager || !this.view || !query) {
return null; return null;
} }
const view = await executeMediaQueryForView( const view = await executeMediaQueryForViewWithErrorDispatching(
this, this,
this.hass,
this.cameraManager, this.cameraManager,
this.view, this.view,
query, query,
@@ -776,7 +777,7 @@ export class FrigateCardTimelineCore extends LitElement {
if (!this.hass || !this.cameraManager) { if (!this.hass || !this.cameraManager) {
return; return;
} }
const cameraMetadata = this.cameraManager.getCameraMetadata(this.hass, cameraID); const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID);
const cameraCapabilities = this.cameraManager.getCameraCapabilities(cameraID); const cameraCapabilities = this.cameraManager.getCameraCapabilities(cameraID);
if (cameraMetadata && cameraCapabilities?.supportsTimeline) { if (cameraMetadata && cameraCapabilities?.supportsTimeline) {
@@ -973,13 +974,7 @@ export class FrigateCardTimelineCore extends LitElement {
* Update the timeline from the view object. * Update the timeline from the view object.
*/ */
protected async _updateTimelineFromView(): Promise<void> { protected async _updateTimelineFromView(): Promise<void> {
if ( if (!this.view || !this.timelineConfig || !this._timelineSource || !this._timeline) {
!this.hass ||
!this.view ||
!this.timelineConfig ||
!this._timelineSource ||
!this._timeline
) {
return; return;
} }
@@ -1029,7 +1024,7 @@ export class FrigateCardTimelineCore extends LitElement {
// (via fetchIfNecessary) may update the timeline contents which causes // (via fetchIfNecessary) may update the timeline contents which causes
// the visjs timeline to stop dragging/panning operations which is very // the visjs timeline to stop dragging/panning operations which is very
// disruptive to the user. // disruptive to the user.
await this._timelineSource?.refresh(this.hass, prefetchedWindow); await this._timelineSource?.refresh(prefetchedWindow);
} }
const currentSelection = this._timeline.getSelection(); const currentSelection = this._timeline.getSelection();
+2 -1
View File
@@ -1,8 +1,9 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { CameraManager } from '../camera-manager/manager'; import { CameraManager } from '../camera-manager/manager';
import { CardWideConfig, TimelineConfig } from '../config/types';
import basicBlockStyle from '../scss/basic-block.scss'; import basicBlockStyle from '../scss/basic-block.scss';
import { CardWideConfig, ExtendedHomeAssistant, TimelineConfig } from '../types'; import { ExtendedHomeAssistant } from '../types';
import { View } from '../view/view'; import { View } from '../view/view';
import './surround.js'; import './surround.js';
import './timeline-core.js'; import './timeline-core.js';
+9 -2
View File
@@ -1,8 +1,8 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { TitleControlConfig } from '../config/types';
import titleStyle from '../scss/title-control.scss'; import titleStyle from '../scss/title-control.scss';
import { TitleControlConfig } from '../types.js';
import { Timer } from '../utils/timer'; import { Timer } from '../utils/timer';
import { View } from '../view/view.js'; import { View } from '../view/view.js';
@@ -64,7 +64,14 @@ export class FrigateCardTitleControl extends LitElement {
protected _toastRef: Ref<PaperToast> = createRef(); protected _toastRef: Ref<PaperToast> = createRef();
protected render(): TemplateResult { protected render(): TemplateResult {
if (!this.text || !this.config || this.config.mode == 'none' || !this.fitInto) { if (
!this.text ||
!this.config ||
!this.config.mode ||
this.config.duration_seconds === undefined ||
this.config.mode === 'none' ||
!this.fitInto
) {
return html``; return html``;
} }
+9 -16
View File
@@ -16,6 +16,12 @@ import {
renderMessage, renderMessage,
renderProgressIndicator, renderProgressIndicator,
} from '../components/message.js'; } from '../components/message.js';
import {
CardWideConfig,
frigateCardConfigDefaults,
TransitionEffect,
ViewerConfig,
} from '../config/types.js';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
import '../patches/ha-hls-player'; import '../patches/ha-hls-player';
import basicBlockStyle from '../scss/basic-block.scss'; import basicBlockStyle from '../scss/basic-block.scss';
@@ -23,13 +29,9 @@ import viewerCarouselStyle from '../scss/viewer-carousel.scss';
import viewerProviderStyle from '../scss/viewer-provider.scss'; import viewerProviderStyle from '../scss/viewer-provider.scss';
import viewerStyle from '../scss/viewer.scss'; import viewerStyle from '../scss/viewer.scss';
import { import {
CardWideConfig,
ExtendedHomeAssistant, ExtendedHomeAssistant,
frigateCardConfigDefaults,
FrigateCardMediaPlayer, FrigateCardMediaPlayer,
MediaLoadedInfo, MediaLoadedInfo,
TransitionEffect,
ViewerConfig,
} from '../types.js'; } from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { mayHaveAudio } from '../utils/audio.js'; import { mayHaveAudio } from '../utils/audio.js';
@@ -151,7 +153,6 @@ export class FrigateCardViewer extends LitElement {
if (mediaType === 'recordings') { if (mediaType === 'recordings') {
changeViewToRecentRecordingForCameraAndDependents( changeViewToRecentRecordingForCameraAndDependents(
this, this,
this.hass,
this.cameraManager, this.cameraManager,
this.cardWideConfig, this.cardWideConfig,
this.view, this.view,
@@ -163,7 +164,6 @@ export class FrigateCardViewer extends LitElement {
} else { } else {
changeViewToRecentEventsForCameraAndDependents( changeViewToRecentEventsForCameraAndDependents(
this, this,
this.hass,
this.cameraManager, this.cameraManager,
this.cardWideConfig, this.cardWideConfig,
this.view, this.view,
@@ -447,7 +447,6 @@ export class FrigateCardViewerCarousel extends LitElement {
}; };
const cameraMetadata = this.cameraManager.getCameraMetadata( const cameraMetadata = this.cameraManager.getCameraMetadata(
this.hass,
selectedMedia.getCameraID(), selectedMedia.getCameraID(),
); );
@@ -526,12 +525,7 @@ export class FrigateCardViewerCarousel extends LitElement {
*/ */
protected async _seekHandler(): Promise<void> { protected async _seekHandler(): Promise<void> {
const seek = this.view?.context?.mediaViewer?.seek; const seek = this.view?.context?.mediaViewer?.seek;
if ( if (!this.hass || !seek || !this._media || !this._player) {
!this.hass ||
!seek ||
!this._media ||
!this._player
) {
return; return;
} }
const selectedMedia = this._media[this._selected]; const selectedMedia = this._media[this._selected];
@@ -548,8 +542,7 @@ export class FrigateCardViewerCarousel extends LitElement {
} }
const seekTime = const seekTime =
(await this.cameraManager?.getMediaSeekTime(this.hass, selectedMedia, seek)) ?? (await this.cameraManager?.getMediaSeekTime(selectedMedia, seek)) ?? null;
null;
if (seekTime !== null) { if (seekTime !== null) {
this._player.seek(seekTime); this._player.seek(seekTime);
@@ -813,7 +806,7 @@ export class FrigateCardViewerProvider
let mediaArray: ViewMedia[] | null; let mediaArray: ViewMedia[] | null;
try { try {
mediaArray = await this.cameraManager.executeMediaQueries(this.hass, queries); mediaArray = await this.cameraManager.executeMediaQueries(queries);
} catch (e) { } catch (e) {
errorToConsole(e as Error); errorToConsole(e as Error);
return; return;
+46 -32
View File
@@ -9,13 +9,17 @@ import {
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../camera-manager/manager.js'; import { CameraManager } from '../camera-manager/manager.js';
import { ConditionControllerEpoch, getOverridesByKey } from '../conditions'; import { ConditionsManagerEpoch, getOverridesByKey } from '../card-controller/conditions-manager.js';
import viewsStyle from '../scss/views.scss'; import viewsStyle from '../scss/views.scss';
import { CardWideConfig, ExtendedHomeAssistant, FrigateCardConfig } from '../types.js'; import { ExtendedHomeAssistant } from '../types.js';
import { ResolvedMediaCache } from '../utils/ha/resolved-media'; import { ConfigManager } from '../card-controller/config-manager.js';
import { ResolvedMediaCache } from '../utils/ha/resolved-media.js';
import { View } from '../view/view.js'; import { View } from '../view/view.js';
import './surround.js'; import './surround.js';
// As a special case: Diagnostics is not dynamically loaded in case something goes wrong.
import './diagnostics.js';
@customElement('frigate-card-views') @customElement('frigate-card-views')
export class FrigateCardViews extends LitElement { export class FrigateCardViews extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
@@ -28,19 +32,13 @@ export class FrigateCardViews extends LitElement {
public cameraManager?: CameraManager; public cameraManager?: CameraManager;
@property({ attribute: false }) @property({ attribute: false })
public config?: FrigateCardConfig; public configManager?: ConfigManager;
@property({ attribute: false })
public nonOverriddenConfig?: FrigateCardConfig;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@property({ attribute: false }) @property({ attribute: false })
public resolvedMediaCache?: ResolvedMediaCache; public resolvedMediaCache?: ResolvedMediaCache;
@property({ attribute: false }) @property({ attribute: false })
public conditionControllerEpoch?: ConditionControllerEpoch; public conditionsManagerEpoch?: ConditionsManagerEpoch;
@property({ attribute: false }) @property({ attribute: false })
public hide?: boolean; public hide?: boolean;
@@ -93,15 +91,24 @@ export class FrigateCardViews extends LitElement {
} }
protected _shouldLivePreload(): boolean { protected _shouldLivePreload(): boolean {
return !!this.config?.live.preload; return (
// Special case: Never preload for diagnostics -- we want that to be as
// minimal as possible.
!!this.configManager?.getConfig()?.live.preload && !this.view?.is('diagnostics')
);
} }
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
const config = this.configManager?.getConfig();
const nonOverriddenConfig = this.configManager?.getNonOverriddenConfig();
const cardWideConfig = this.configManager?.getCardWideConfig();
const rawConfig = this.configManager?.getRawConfig();
// Only essential items should be added to the below list, since we want the // Only essential items should be added to the below list, since we want the
// overall views pane to render in ~almost all cases (e.g. for a camera // overall views pane to render in ~almost all cases (e.g. for a camera
// initialization error to display, `view` and `cameraConfig` may both be // initialization error to display, `view` and `cameraConfig` may both be
// undefined, but we still want to render). // undefined, but we still want to render).
if (!this.hass || !this.config || !this.nonOverriddenConfig) { if (!this.hass || !config || !nonOverriddenConfig || !cardWideConfig) {
return html``; return html``;
} }
@@ -115,17 +122,17 @@ export class FrigateCardViews extends LitElement {
}; };
const thumbnailConfig = this.view?.is('live') const thumbnailConfig = this.view?.is('live')
? this.config.live.controls.thumbnails ? config.live.controls.thumbnails
: this.view?.isViewerView() : this.view?.isViewerView()
? this.config.media_viewer.controls.thumbnails ? config.media_viewer.controls.thumbnails
: this.view?.is('timeline') : this.view?.is('timeline')
? this.config.timeline.controls.thumbnails ? config.timeline.controls.thumbnails
: undefined; : undefined;
const miniTimelineConfig = this.view?.is('live') const miniTimelineConfig = this.view?.is('live')
? this.config.live.controls.timeline ? config.live.controls.timeline
: this.view?.isViewerView() : this.view?.isViewerView()
? this.config.media_viewer.controls.timeline ? config.media_viewer.controls.timeline
: undefined; : undefined;
const cameraConfig = this.view const cameraConfig = this.view
@@ -137,16 +144,16 @@ export class FrigateCardViews extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.fetchMedia=${this.view?.is('live') .fetchMedia=${this.view?.is('live')
? this.config.live.controls.thumbnails.media ? config.live.controls.thumbnails.media
: undefined} : undefined}
.thumbnailConfig=${!this.hide ? thumbnailConfig : undefined} .thumbnailConfig=${!this.hide ? thumbnailConfig : undefined}
.timelineConfig=${!this.hide ? miniTimelineConfig : undefined} .timelineConfig=${!this.hide ? miniTimelineConfig : undefined}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${cardWideConfig}
> >
${!this.hide && this.view?.is('image') && cameraConfig ${!this.hide && this.view?.is('image') && cameraConfig
? html` <frigate-card-image ? html` <frigate-card-image
.imageConfig=${this.config.image} .imageConfig=${config.image}
.view=${this.view} .view=${this.view}
.hass=${this.hass} .hass=${this.hass}
.cameraConfig=${cameraConfig} .cameraConfig=${cameraConfig}
@@ -158,9 +165,9 @@ export class FrigateCardViews extends LitElement {
? html` <frigate-card-gallery ? html` <frigate-card-gallery
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.galleryConfig=${this.config.media_gallery} .galleryConfig=${config.media_gallery}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${cardWideConfig}
> >
</frigate-card-gallery>` </frigate-card-gallery>`
: ``} : ``}
@@ -169,10 +176,10 @@ export class FrigateCardViews extends LitElement {
<frigate-card-viewer <frigate-card-viewer
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.viewerConfig=${this.config.media_viewer} .viewerConfig=${config.media_viewer}
.resolvedMediaCache=${this.resolvedMediaCache} .resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${cardWideConfig}
> >
</frigate-card-viewer> </frigate-card-viewer>
` `
@@ -181,12 +188,19 @@ export class FrigateCardViews extends LitElement {
? html` <frigate-card-timeline ? html` <frigate-card-timeline
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.timelineConfig=${this.config.timeline} .timelineConfig=${config.timeline}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${cardWideConfig}
> >
</frigate-card-timeline>` </frigate-card-timeline>`
: ``} : ``}
${!this.hide && this.view?.is('diagnostics')
? html` <frigate-card-diagnostics
.hass=${this.hass}
.rawConfig=${rawConfig}
>
</frigate-card-diagnostics>`
: ``}
${ ${
// Note: Subtle difference in condition below vs the other views in order // Note: Subtle difference in condition below vs the other views in order
// to always render the live view for live.preload mode. // to always render the live view for live.preload mode.
@@ -199,12 +213,12 @@ export class FrigateCardViews extends LitElement {
<frigate-card-live <frigate-card-live
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.nonOverriddenLiveConfig=${this.nonOverriddenConfig.live} .nonOverriddenLiveConfig=${nonOverriddenConfig.live}
.overriddenLiveConfig=${this.config.live} .overriddenLiveConfig=${config.live}
.conditionControllerEpoch=${this.conditionControllerEpoch} .conditionsManagerEpoch=${this.conditionsManagerEpoch}
.liveOverrides=${getOverridesByKey('live', this.config.overrides)} .liveOverrides=${getOverridesByKey('live', config.overrides)}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${cardWideConfig}
.microphoneStream=${this.microphoneStream} .microphoneStream=${this.microphoneStream}
class="${classMap(liveClasses)}" class="${classMap(liveClasses)}"
> >
+6 -6
View File
@@ -2,6 +2,12 @@ import cloneDeep from 'lodash-es/cloneDeep';
import get from 'lodash-es/get'; import get from 'lodash-es/get';
import isEqual from 'lodash-es/isEqual'; import isEqual from 'lodash-es/isEqual';
import set from 'lodash-es/set'; import set from 'lodash-es/set';
import {
BUTTON_SIZE_MIN,
RawFrigateCardConfig,
THUMBNAIL_WIDTH_MAX,
THUMBNAIL_WIDTH_MIN,
} from './config/types';
import { import {
CONF_CAMERAS, CONF_CAMERAS,
CONF_CAMERAS_GLOBAL_IMAGE, CONF_CAMERAS_GLOBAL_IMAGE,
@@ -28,12 +34,6 @@ import {
CONF_MENU_STYLE, CONF_MENU_STYLE,
CONF_OVERRIDES, CONF_OVERRIDES,
} from './const'; } from './const';
import {
BUTTON_SIZE_MIN,
RawFrigateCardConfig,
THUMBNAIL_WIDTH_MAX,
THUMBNAIL_WIDTH_MIN,
} from './types';
import { arrayify } from './utils/basic'; import { arrayify } from './utils/basic';
/** /**
+1512
View File
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -16,6 +16,16 @@ import {
setConfigValue, setConfigValue,
upgradeConfig, upgradeConfig,
} from './config-mgmt.js'; } from './config-mgmt.js';
import {
BUTTON_SIZE_MIN,
FRIGATE_MENU_PRIORITY_MAX,
FrigateCardConfig,
frigateCardConfigDefaults,
RawFrigateCardConfig,
RawFrigateCardConfigArray,
THUMBNAIL_WIDTH_MAX,
THUMBNAIL_WIDTH_MIN,
} from './config/types.js';
import { import {
CONF_CAMERAS, CONF_CAMERAS,
CONF_CAMERAS_ARRAY_CAMERA_ENTITY, CONF_CAMERAS_ARRAY_CAMERA_ENTITY,
@@ -134,8 +144,8 @@ import {
CONF_MEDIA_VIEWER_TRANSITION_EFFECT, CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
CONF_MEDIA_VIEWER_ZOOMABLE, CONF_MEDIA_VIEWER_ZOOMABLE,
CONF_MENU_ALIGNMENT, CONF_MENU_ALIGNMENT,
CONF_MENU_BUTTONS,
CONF_MENU_BUTTON_SIZE, CONF_MENU_BUTTON_SIZE,
CONF_MENU_BUTTONS,
CONF_MENU_POSITION, CONF_MENU_POSITION,
CONF_MENU_STYLE, CONF_MENU_STYLE,
CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR, CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR,
@@ -171,16 +181,6 @@ import {
import { localize } from './localize/localize.js'; import { localize } from './localize/localize.js';
import { setLowPerformanceProfile } from './performance.js'; import { setLowPerformanceProfile } from './performance.js';
import frigate_card_editor_style from './scss/editor.scss'; import frigate_card_editor_style from './scss/editor.scss';
import {
BUTTON_SIZE_MIN,
FrigateCardConfig,
frigateCardConfigDefaults,
FRIGATE_MENU_PRIORITY_MAX,
RawFrigateCardConfig,
RawFrigateCardConfigArray,
THUMBNAIL_WIDTH_MAX,
THUMBNAIL_WIDTH_MIN,
} from './types.js';
import { arrayMove, prettifyTitle } from './utils/basic.js'; import { arrayMove, prettifyTitle } from './utils/basic.js';
import { getCameraID } from './utils/camera.js'; import { getCameraID } from './utils/camera.js';
import { import {
+6 -6
View File
@@ -1,10 +1,9 @@
import { deepRemoveDefaults } from './utils/zod.js';
import {
frigateCardConfigSchema,
RawFrigateCardConfig,
PerformanceConfig,
} from './types';
import { getConfigValue, setConfigValue } from './config-mgmt.js'; import { getConfigValue, setConfigValue } from './config-mgmt.js';
import {
PerformanceConfig,
RawFrigateCardConfig,
frigateCardConfigSchema,
} from './config/types.js';
import { import {
CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS, CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS,
CONF_CAMERAS_GLOBAL_TRIGGERS_OCCUPANCY, CONF_CAMERAS_GLOBAL_TRIGGERS_OCCUPANCY,
@@ -53,6 +52,7 @@ import {
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
CONF_TIMELINE_SHOW_RECORDINGS, CONF_TIMELINE_SHOW_RECORDINGS,
} from './const.js'; } from './const.js';
import { deepRemoveDefaults } from './utils/zod.js';
// Caution: These values are applied after parsing (since we cannot know the // Caution: These values are applied after parsing (since we cannot know the
// performance profile until afterwards), so there is no validation on these // performance profile until afterwards), so there is no validation on these
+11 -1477
View File
File diff suppressed because it is too large Load Diff
+1 -20
View File
@@ -10,9 +10,8 @@ import {
FrigateCardAction, FrigateCardAction,
FrigateCardCustomAction, FrigateCardCustomAction,
frigateCardCustomActionSchema, frigateCardCustomActionSchema,
FrigateCardViewAction,
ViewDisplayMode, ViewDisplayMode,
} from '../types.js'; } from '../config/types.js';
/** /**
* Convert a generic Action to a FrigateCardCustomAction if it parses correctly. * Convert a generic Action to a FrigateCardCustomAction if it parses correctly.
@@ -187,21 +186,3 @@ export const frigateCardHasAction = (config?: ActionType | ActionType[]): boolea
export const stopEventFromActivatingCardWideActions = (ev: Event): void => { export const stopEventFromActivatingCardWideActions = (ev: Event): void => {
ev.stopPropagation(); ev.stopPropagation();
}; };
export const isViewAction = (
action: FrigateCardCustomAction,
): action is FrigateCardViewAction => {
switch (action.frigate_card_action) {
case 'clip':
case 'clips':
case 'image':
case 'live':
case 'recording':
case 'recordings':
case 'snapshot':
case 'snapshots':
case 'timeline':
return true;
}
return false;
};
+1 -1
View File
@@ -1,5 +1,5 @@
import { CameraManager } from '../camera-manager/manager.js'; import { CameraManager } from '../camera-manager/manager.js';
import { CameraConfig, RawFrigateCardConfig } from '../types.js'; import { CameraConfig, RawFrigateCardConfig } from '../config/types.js';
/** /**
* Get a camera id. * Get a camera id.
+2 -2
View File
@@ -1,6 +1,6 @@
import { CardWideConfig } from '../types'; import { CardWideConfig } from '../config/types';
export const log = (cardWideConfig?: CardWideConfig, ...args: unknown[]) => { export const log = (cardWideConfig?: CardWideConfig | null, ...args: unknown[]) => {
if (cardWideConfig?.debug?.logging) { if (cardWideConfig?.debug?.logging) {
console.debug(...args); console.debug(...args);
} }
+70
View File
@@ -0,0 +1,70 @@
import { HomeAssistant } from 'custom-card-helpers';
import pkg from '../../package.json';
import { RawFrigateCardConfig } from '../config/types';
import { getLanguage } from '../localize/localize';
import { DeviceList, getAllDevices } from './ha/device-registry';
type FrigateVersions = Record<string, string>;
interface GitDiagnostics {
build_version?: string;
build_date?: string;
commit_date?: string;
}
export interface Diagnostics {
card_version: string;
browser: string;
date: Date;
lang: string;
timezone: string;
git: GitDiagnostics;
frigate_versions?: FrigateVersions;
ha_version?: string;
config?: RawFrigateCardConfig;
}
export const getDiagnostics = async (
hass?: HomeAssistant,
rawConfig?: RawFrigateCardConfig,
): Promise<Diagnostics> => {
let devices: DeviceList | undefined = [];
if (hass) {
try {
devices = await getAllDevices(hass);
} catch (e) {
// Pass. This is optional.
}
}
// Get the Frigate devices in order to extract the Frigate integration and
// server version numbers.
const frigateDevices = devices?.filter((device) => device.manufacturer === 'Frigate');
const frigateVersionMap: Map<string, string> = new Map();
frigateDevices?.forEach((device) => {
device.config_entries.forEach((configEntry) => {
if (device.model) {
frigateVersionMap.set(configEntry, device.model);
}
});
});
return {
card_version: pkg.version,
browser: navigator.userAgent,
date: new Date(),
...(frigateVersionMap.size && {
frigate_versions: Object.fromEntries(frigateVersionMap),
}),
lang: getLanguage(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
git: {
...(pkg['gitVersion'] && { build_version: pkg['gitVersion'] }),
...(pkg['buildDate'] && { build_date: pkg['buildDate'] }),
...(pkg['gitDate'] && { commit_date: pkg['gitDate'] }),
},
...(hass && { ha_version: hass.config.version }),
...(rawConfig && { config: rawConfig }),
};
};
+1 -1
View File
@@ -39,7 +39,7 @@ export const downloadMedia = async (
cameraManager: CameraManager, cameraManager: CameraManager,
media: ViewMedia, media: ViewMedia,
): Promise<void> => { ): Promise<void> => {
const download = await cameraManager.getMediaDownloadPath(hass, media); const download = await cameraManager.getMediaDownloadPath(media);
if (!download) { if (!download) {
throw new FrigateCardError(localize('error.download_no_media')); throw new FrigateCardError(localize('error.download_no_media'));
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel';
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins'; import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
import isEqual from 'lodash-es/isEqual'; import isEqual from 'lodash-es/isEqual';
import { TransitionEffect } from '../../types'; import { TransitionEffect } from '../../config/types';
import { dispatchFrigateCardEvent, getChildrenFromElement } from '../basic.js'; import { dispatchFrigateCardEvent, getChildrenFromElement } from '../basic.js';
export interface CarouselSelected { export interface CarouselSelected {
@@ -2,7 +2,7 @@ import { EmblaCarouselType, EmblaEventType } from 'embla-carousel';
import { CreateOptionsType } from 'embla-carousel/components/Options'; import { CreateOptionsType } from 'embla-carousel/components/Options';
import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler'; import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler';
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins'; import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
import { LazyUnloadCondition } from '../../../../types'; import { LazyUnloadCondition } from '../../../../config/types';
declare module 'embla-carousel/components/Plugins' { declare module 'embla-carousel/components/Plugins' {
interface EmblaPluginsType { interface EmblaPluginsType {
@@ -7,8 +7,8 @@ import {
AutoPauseCondition, AutoPauseCondition,
AutoPlayCondition, AutoPlayCondition,
AutoUnmuteCondition, AutoUnmuteCondition,
FrigateCardMediaPlayer, } from '../../../../config/types.js';
} from '../../../../types.js'; import { FrigateCardMediaPlayer } from '../../../../types.js';
declare module 'embla-carousel/components/Plugins' { declare module 'embla-carousel/components/Plugins' {
interface EmblaPluginsType { interface EmblaPluginsType {
+16
View File
@@ -364,3 +364,19 @@ export function canonicalizeHAURL(
} }
return url ?? null; return url ?? null;
} }
/**
* Determine if HA connection state has changed.
* @param newHass The new HA object.
* @param oldHass The old HA object.
* @returns `true` if the connection state has changed.
*/
export const hasHAConnectionStateChanged = (
oldHass: HomeAssistant | undefined | null,
newHass: HomeAssistant | undefined | null,
): boolean => {
return (
(!oldHass && !newHass?.connected) ||
(!!oldHass && oldHass.connected !== !!newHass?.connected)
);
};
@@ -1,16 +1,16 @@
import { allPromises } from './basic'; import { allPromises } from '../basic';
enum InitializationState { enum InitializationState {
INITIALIZING = 'initializing', INITIALIZING = 'initializing',
INITIALIZED = 'initialized', INITIALIZED = 'initialized',
} }
type Initializer = () => Promise<unknown>; type InitializationCallback = () => Promise<unknown>;
/** /**
* Manages initialization state & calling initializers. * Manages initialization state & calling initializers.
*/ */
export class FrigateCardInitializer { export class Initializer {
protected _state: Map<string, InitializationState>; protected _state: Map<string, InitializationState>;
constructor() { constructor() {
@@ -18,7 +18,7 @@ export class FrigateCardInitializer {
} }
public async initializeMultipleIfNecessary( public async initializeMultipleIfNecessary(
aspects: Record<string, Initializer>, aspects: Record<string, InitializationCallback>,
): Promise<boolean> { ): Promise<boolean> {
const results = await allPromises( const results = await allPromises(
Object.entries(aspects), Object.entries(aspects),
@@ -36,7 +36,7 @@ export class FrigateCardInitializer {
*/ */
public async initializeIfNecessary( public async initializeIfNecessary(
aspect: string, aspect: string,
initializer?: Initializer, initializer?: InitializationCallback,
): Promise<boolean> { ): Promise<boolean> {
const state = this._state.get(aspect); const state = this._state.get(aspect);
if (state !== InitializationState.INITIALIZED) { if (state !== InitializationState.INITIALIZED) {
+2 -1
View File
@@ -1,7 +1,8 @@
import isEqual from 'lodash-es/isEqual'; import isEqual from 'lodash-es/isEqual';
import throttle from 'lodash-es/throttle'; import throttle from 'lodash-es/throttle';
import Masonry from 'masonry-layout'; import Masonry from 'masonry-layout';
import { MediaLoadedInfo, ViewDisplayConfig } from '../types'; import { ViewDisplayConfig } from '../config/types';
import { MediaLoadedInfo } from '../types';
import { import {
dispatchFrigateCardEvent, dispatchFrigateCardEvent,
getChildrenFromElement, getChildrenFromElement,
-27
View File
@@ -1,27 +0,0 @@
import { MediaLoadedInfo } from '../types';
export class MediaLoadedInfoController {
protected _current: MediaLoadedInfo | null = null;
protected _lastKnown: MediaLoadedInfo | null = null;
public set(current: MediaLoadedInfo): void {
this._current = current;
this._lastKnown = current;
}
public get(): MediaLoadedInfo | null {
return this._current;
}
public getLastKnown(): MediaLoadedInfo | null {
return this._lastKnown;
}
public clear(): void {
this._current = null;
}
public has(): boolean {
return !!this._current;
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { MediaLayoutConfig } from '../types'; import { MediaLayoutConfig } from '../config/types';
/** /**
* Update element style from a media configuration. * Update element style from a media configuration.
+49 -24
View File
@@ -1,10 +1,10 @@
import { HomeAssistant } from 'custom-card-helpers';
import { ViewContext } from 'view'; import { ViewContext } from 'view';
import { CameraManager } from '../camera-manager/manager'; import { CameraManager } from '../camera-manager/manager';
import { MediaQuery } from '../camera-manager/types'; import { MediaQuery } from '../camera-manager/types';
import { dispatchFrigateCardErrorEvent } from '../components/message'; import { dispatchFrigateCardErrorEvent } from '../components/message';
import { CardWideConfig, FrigateCardView } from '../config/types';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const';
import { CardWideConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types'; import { ClipsOrSnapshotsOrAll } from '../types';
import { ViewMedia } from '../view/media'; import { ViewMedia } from '../view/media';
import { import {
EventMediaQueries, EventMediaQueries,
@@ -20,7 +20,6 @@ type ResultSelectType = 'latest' | 'time' | 'none';
export const changeViewToRecentEventsForCameraAndDependents = async ( export const changeViewToRecentEventsForCameraAndDependents = async (
element: HTMLElement, element: HTMLElement,
hass: HomeAssistant,
cameraManager: CameraManager, cameraManager: CameraManager,
cardWideConfig: CardWideConfig, cardWideConfig: CardWideConfig,
view: View, view: View,
@@ -46,10 +45,16 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
} }
( (
await executeMediaQueryForView(element, hass, cameraManager, view, queries, { await executeMediaQueryForViewWithErrorDispatching(
targetView: options?.targetView, element,
select: options?.select, cameraManager,
}) view,
queries,
{
targetView: options?.targetView,
select: options?.select,
},
)
)?.dispatchChangeEvent(element); )?.dispatchChangeEvent(element);
}; };
@@ -82,7 +87,6 @@ const createQueriesForEventsView = (
*/ */
export const changeViewToRecentRecordingForCameraAndDependents = async ( export const changeViewToRecentRecordingForCameraAndDependents = async (
element: HTMLElement, element: HTMLElement,
hass: HomeAssistant,
cameraManager: CameraManager, cameraManager: CameraManager,
cardWideConfig: CardWideConfig, cardWideConfig: CardWideConfig,
view: View, view: View,
@@ -109,10 +113,16 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
} }
( (
await executeMediaQueryForView(element, hass, cameraManager, view, queries, { await executeMediaQueryForViewWithErrorDispatching(
targetView: options?.targetView, element,
select: options?.select, cameraManager,
}) view,
queries,
{
targetView: options?.targetView,
select: options?.select,
},
)
)?.dispatchChangeEvent(element); )?.dispatchChangeEvent(element);
}; };
@@ -130,8 +140,6 @@ const createQueriesForRecordingsView = (
}; };
export const executeMediaQueryForView = async ( export const executeMediaQueryForView = async (
element: HTMLElement,
hass: HomeAssistant,
cameraManager: CameraManager, cameraManager: CameraManager,
view: View, view: View,
query: MediaQueries, query: MediaQueries,
@@ -142,21 +150,12 @@ export const executeMediaQueryForView = async (
select?: ResultSelectType; select?: ResultSelectType;
}, },
): Promise<View | null> => { ): Promise<View | null> => {
let mediaArray: ViewMedia[] | null;
const queries = query.getQueries(); const queries = query.getQueries();
if (!queries) { if (!queries) {
return null; return null;
} }
try { const mediaArray = await cameraManager.executeMediaQueries<MediaQuery>(queries);
mediaArray = await cameraManager.executeMediaQueries<MediaQuery>(hass, queries);
} catch (e) {
errorToConsole(e as Error);
dispatchFrigateCardErrorEvent(element, e as Error);
return null;
}
if (!mediaArray) { if (!mediaArray) {
return null; return null;
} }
@@ -186,6 +185,32 @@ export const executeMediaQueryForView = async (
.mergeInContext(viewerContext); .mergeInContext(viewerContext);
}; };
export const executeMediaQueryForViewWithErrorDispatching = async (
element: HTMLElement,
cameraManager: CameraManager,
view: View,
query: MediaQueries,
options?: {
targetCameraID?: string;
targetView?: FrigateCardView;
targetTime?: Date;
select?: ResultSelectType;
},
): Promise<View | null> => {
try {
return await executeMediaQueryForView(cameraManager, view, query, {
targetCameraID: options?.targetCameraID,
targetView: options?.targetView,
targetTime: options?.targetTime,
select: options?.select,
});
} catch (e: unknown) {
errorToConsole(e as Error);
dispatchFrigateCardErrorEvent(element, e as Error);
}
return null;
};
/** /**
* Find the longest matching media object that contains a given targetTime. * Find the longest matching media object that contains a given targetTime.
* Longest is chosen to give the most stability to the media viewer. * Longest is chosen to give the most stability to the media viewer.
+65 -59
View File
@@ -1,34 +1,44 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { StyleInfo } from 'lit/directives/style-map'; import { StyleInfo } from 'lit/directives/style-map';
import screenfull from 'screenfull';
import { CameraManager } from '../camera-manager/manager'; import { CameraManager } from '../camera-manager/manager';
import {
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
FrigateCardConfig,
FrigateCardCustomAction,
MenuItem
} from '../config/types';
import { FRIGATE_BUTTON_MENU_ICON } from '../const'; import { FRIGATE_BUTTON_MENU_ICON } from '../const';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
import { import {
FrigateCardConfig,
FrigateCardCustomAction,
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
MediaLoadedInfo, MediaLoadedInfo,
MenuButton,
} from '../types'; } from '../types';
import { View } from '../view/view'; import { View } from '../view/view';
import { createFrigateCardCustomAction } from './action'; import { createFrigateCardCustomAction } from './action';
import { getAllDependentCameras } from './camera'; import { getAllDependentCameras } from './camera';
import { MediaPlayerManager } from '../card-controller/media-player-manager';
import { MicrophoneManager } from '../card-controller/microphone-manager';
import { getEntityIcon, getEntityTitle } from './ha'; import { getEntityIcon, getEntityTitle } from './ha';
import { MicrophoneController } from './microphone';
import { hasSubstream } from './substream'; import { hasSubstream } from './substream';
export interface MenuButtonControllerOptions {
currentMediaLoadedInfo?: MediaLoadedInfo | null;
showCameraUIButton?: boolean;
inFullscreenMode?: boolean;
inExpandedMode?: boolean;
microphoneManager?: MicrophoneManager | null;
mediaPlayerController?: MediaPlayerManager | null;
}
export class MenuButtonController { export class MenuButtonController {
// Array of dynamic menu buttons to be added to menu. // Array of dynamic menu buttons to be added to menu.
protected _dynamicMenuButtons: MenuButton[] = []; protected _dynamicMenuButtons: MenuItem[] = [];
public addDynamicMenuButton(button: MenuButton): void { public addDynamicMenuButton(button: MenuItem): void {
if (!this._dynamicMenuButtons.includes(button)) { if (!this._dynamicMenuButtons.includes(button)) {
this._dynamicMenuButtons.push(button); this._dynamicMenuButtons.push(button);
} }
} }
public removeDynamicMenuButton(button: MenuButton): void { public removeDynamicMenuButton(button: MenuItem): void {
this._dynamicMenuButtons = this._dynamicMenuButtons.filter( this._dynamicMenuButtons = this._dynamicMenuButtons.filter(
(existingButton) => existingButton != button, (existingButton) => existingButton != button,
); );
@@ -43,14 +53,8 @@ export class MenuButtonController {
config: FrigateCardConfig, config: FrigateCardConfig,
cameraManager: CameraManager, cameraManager: CameraManager,
view: View, view: View,
expanded: boolean, options?: MenuButtonControllerOptions,
options?: { ): MenuItem[] {
currentMediaLoadedInfo?: MediaLoadedInfo | null;
mediaPlayers?: string[];
cameraURL?: string | null;
microphoneController?: MicrophoneController;
},
): MenuButton[] {
const visibleCameras = cameraManager.getStore().getVisibleCameras(); const visibleCameras = cameraManager.getStore().getVisibleCameras();
const selectedCameraID = view.camera; const selectedCameraID = view.camera;
const selectedCameraConfig = cameraManager const selectedCameraConfig = cameraManager
@@ -65,7 +69,7 @@ export class MenuButtonController {
? cameraManager?.getMediaCapabilities(selectedMedia) ? cameraManager?.getMediaCapabilities(selectedMedia)
: null; : null;
const buttons: MenuButton[] = []; const buttons: MenuItem[] = [];
buttons.push({ buttons.push({
// Use a magic icon value that the menu will use to render the custom // Use a magic icon value that the menu will use to render the custom
// Frigate icon. // Frigate icon.
@@ -87,7 +91,7 @@ export class MenuButtonController {
const action = createFrigateCardCustomAction('camera_select', { const action = createFrigateCardCustomAction('camera_select', {
camera: cameraID, camera: cameraID,
}); });
const metadata = cameraManager.getCameraMetadata(hass, cameraID) ?? undefined; const metadata = cameraManager.getCameraMetadata(cameraID) ?? undefined;
return { return {
enabled: true, enabled: true,
@@ -132,7 +136,7 @@ export class MenuButtonController {
const action = createFrigateCardCustomAction('live_substream_select', { const action = createFrigateCardCustomAction('live_substream_select', {
camera: cameraID, camera: cameraID,
}); });
const metadata = cameraManager.getCameraMetadata(hass, cameraID) ?? undefined; const metadata = cameraManager.getCameraMetadata(cameraID) ?? undefined;
const cameraConfig = cameraManager.getStore().getCameraConfig(cameraID); const cameraConfig = cameraManager.getStore().getCameraConfig(cameraID);
return { return {
enabled: true, enabled: true,
@@ -244,7 +248,7 @@ export class MenuButtonController {
}); });
} }
if (options?.cameraURL) { if (options?.showCameraUIButton) {
buttons.push({ buttons.push({
icon: 'mdi:web', icon: 'mdi:web',
...config.menu.buttons.camera_ui, ...config.menu.buttons.camera_ui,
@@ -257,11 +261,11 @@ export class MenuButtonController {
} }
if ( if (
options?.microphoneController && options?.microphoneManager &&
options?.currentMediaLoadedInfo?.capabilities?.supports2WayAudio options?.currentMediaLoadedInfo?.capabilities?.supports2WayAudio
) { ) {
const forbidden = options.microphoneController.isForbidden(); const forbidden = options.microphoneManager.isForbidden();
const muted = options.microphoneController.isMuted(); const muted = options.microphoneManager.isMuted();
const buttonType = config.menu.buttons.microphone.type; const buttonType = config.menu.buttons.microphone.type;
buttons.push({ buttons.push({
icon: forbidden icon: forbidden
@@ -285,7 +289,7 @@ export class MenuButtonController {
...(!forbidden && ...(!forbidden &&
buttonType === 'toggle' && { buttonType === 'toggle' && {
tap_action: createFrigateCardCustomAction( tap_action: createFrigateCardCustomAction(
options.microphoneController.isMuted() options.microphoneManager.isMuted()
? 'microphone_unmute' ? 'microphone_unmute'
: 'microphone_mute', : 'microphone_mute',
) as FrigateCardCustomAction, ) as FrigateCardCustomAction,
@@ -293,57 +297,59 @@ export class MenuButtonController {
}); });
} }
if (screenfull.isEnabled && !this._isBeingCasted()) { if (!this._isBeingCasted()) {
buttons.push({ buttons.push({
icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen', icon: options?.inFullscreenMode ? 'mdi:fullscreen-exit' : 'mdi:fullscreen',
...config.menu.buttons.fullscreen, ...config.menu.buttons.fullscreen,
type: 'custom:frigate-card-menu-icon', type: 'custom:frigate-card-menu-icon',
title: localize('config.menu.buttons.fullscreen'), title: localize('config.menu.buttons.fullscreen'),
tap_action: createFrigateCardCustomAction( tap_action: createFrigateCardCustomAction(
'fullscreen', 'fullscreen',
) as FrigateCardCustomAction, ) as FrigateCardCustomAction,
style: screenfull.isFullscreen ? this._getEmphasizedStyle() : {}, style: options?.inFullscreenMode ? this._getEmphasizedStyle() : {},
}); });
} }
buttons.push({ buttons.push({
icon: expanded ? 'mdi:arrow-collapse-all' : 'mdi:arrow-expand-all', icon: options?.inExpandedMode ? 'mdi:arrow-collapse-all' : 'mdi:arrow-expand-all',
...config.menu.buttons.expand, ...config.menu.buttons.expand,
type: 'custom:frigate-card-menu-icon', type: 'custom:frigate-card-menu-icon',
title: localize('config.menu.buttons.expand'), title: localize('config.menu.buttons.expand'),
tap_action: createFrigateCardCustomAction('expand') as FrigateCardCustomAction, tap_action: createFrigateCardCustomAction('expand') as FrigateCardCustomAction,
style: expanded ? this._getEmphasizedStyle() : {}, style: options?.inExpandedMode ? this._getEmphasizedStyle() : {},
}); });
if ( if (
options?.mediaPlayers?.length && options?.mediaPlayerController?.hasMediaPlayers() &&
(view?.isViewerView() || (view.is('live') && selectedCameraConfig?.camera_entity)) (view?.isViewerView() || (view.is('live') && selectedCameraConfig?.camera_entity))
) { ) {
const mediaPlayerItems = options.mediaPlayers.map((playerEntityID) => { const mediaPlayerItems = options.mediaPlayerController
const title = getEntityTitle(hass, playerEntityID) || playerEntityID; .getMediaPlayers()
const state = hass.states[playerEntityID]; .map((playerEntityID) => {
const playAction = createFrigateCardCustomAction('media_player', { const title = getEntityTitle(hass, playerEntityID) || playerEntityID;
media_player: playerEntityID, const state = hass.states[playerEntityID];
media_player_action: 'play', const playAction = createFrigateCardCustomAction('media_player', {
}); media_player: playerEntityID,
const stopAction = createFrigateCardCustomAction('media_player', { media_player_action: 'play',
media_player: playerEntityID, });
media_player_action: 'stop', const stopAction = createFrigateCardCustomAction('media_player', {
}); media_player: playerEntityID,
const disabled = !state || state.state === 'unavailable'; media_player_action: 'stop',
});
const disabled = !state || state.state === 'unavailable';
return { return {
enabled: true, enabled: true,
selected: false, selected: false,
icon: getEntityIcon(hass, playerEntityID), icon: getEntityIcon(hass, playerEntityID),
entity: playerEntityID, entity: playerEntityID,
state_color: false, state_color: false,
title: title, title: title,
disabled: disabled, disabled: disabled,
...(!disabled && playAction && { tap_action: playAction }), ...(!disabled && playAction && { tap_action: playAction }),
...(!disabled && stopAction && { hold_action: stopAction }), ...(!disabled && stopAction && { hold_action: stopAction }),
}; };
}); });
buttons.push({ buttons.push({
icon: 'mdi:cast', icon: 'mdi:cast',
@@ -414,7 +420,7 @@ export class MenuButtonController {
} }
const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({ const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({
style: this._getStyleFromActions(config, view, button), style: this._getStyleFromActions(config, view, button, options),
...button, ...button,
})); }));
@@ -446,7 +452,8 @@ export class MenuButtonController {
protected _getStyleFromActions( protected _getStyleFromActions(
config: FrigateCardConfig, config: FrigateCardConfig,
view: View, view: View,
button: MenuButton, button: MenuItem,
options?: MenuButtonControllerOptions,
): StyleInfo { ): StyleInfo {
for (const actionSet of [ for (const actionSet of [
button.tap_action, button.tap_action,
@@ -476,8 +483,7 @@ export class MenuButtonController {
(frigateCardAction.frigate_card_action === 'default' && (frigateCardAction.frigate_card_action === 'default' &&
view.is(config.view.default)) || view.is(config.view.default)) ||
(frigateCardAction.frigate_card_action === 'fullscreen' && (frigateCardAction.frigate_card_action === 'fullscreen' &&
screenfull.isEnabled && !!options?.inFullscreenMode) ||
screenfull.isFullscreen) ||
(frigateCardAction.frigate_card_action === 'camera_select' && (frigateCardAction.frigate_card_action === 'camera_select' &&
view.camera === frigateCardAction.camera) view.camera === frigateCardAction.camera)
) { ) {
-60
View File
@@ -1,60 +0,0 @@
import { FrigateCardCustomAction } from '../types';
import { createFrigateCardCustomAction } from './action.js';
export const getActionsFromQueryString = (
queryString: string,
): FrigateCardCustomAction[] => {
const params = new URLSearchParams(queryString);
const actions: FrigateCardCustomAction[] = [];
const actionRE = new RegExp(
/^frigate-card-action([.:](?<cardID>\w+))?[.:](?<action>\w+)/,
);
for (const [key, value] of params.entries()) {
const match = key.match(actionRE);
if (!match || !match.groups) {
continue;
}
const cardID: string | undefined = match.groups['cardID'];
const action = match.groups['action'];
let customAction: FrigateCardCustomAction | null = null;
switch (action) {
case 'camera_select':
case 'live_substream_select':
if (value) {
customAction = createFrigateCardCustomAction(action, {
camera: value,
cardID: cardID,
});
}
break;
case 'camera_ui':
case 'clip':
case 'clips':
case 'default':
case 'diagnostics':
case 'download':
case 'expand':
case 'image':
case 'live':
case 'menu_toggle':
case 'recording':
case 'recordings':
case 'snapshot':
case 'snapshots':
case 'timeline':
customAction = createFrigateCardCustomAction(action, {
cardID: cardID,
});
break;
default:
console.warn(
`Frigate card received unknown card action in query string: ${action}`,
);
}
if (customAction) {
actions.push(customAction);
}
}
return actions;
};
+1 -1
View File
@@ -14,7 +14,7 @@ export const screenshotMedia = (video: HTMLVideoElement): string | null => {
return canvas.toDataURL('image/jpeg'); return canvas.toDataURL('image/jpeg');
}; };
export const generateScreenshotTitle = (view?: View): string => { export const generateScreenshotTitle = (view?: View | null): string => {
if (view?.is('live') || view?.is('image')) { if (view?.is('live') || view?.is('image')) {
return `${view.view}-${view.camera}-${format( return `${view.view}-${view.camera}-${format(
new Date(), new Date(),
+3 -42
View File
@@ -1,48 +1,9 @@
import { CameraManager } from '../camera-manager/manager';
import { View } from '../view/view'; import { View } from '../view/view';
import { getAllDependentCameras } from './camera';
export const createViewWithSelectedSubstream = ( export const getStreamCameraID = (view: View): string => {
view: View, return view?.context?.live?.overrides?.get(view.camera) ?? view.camera;
substreamID: string,
): View | null => {
const overrides: Map<string, string> = view.context?.live?.overrides ?? new Map();
overrides.set(view.camera, substreamID);
return view.clone().mergeInContext({
live: { overrides: overrides },
});
};
export const createViewWithoutSubstream = (view: View): View => {
const newView = view.clone();
const overrides: Map<string, string> | undefined = newView.context?.live?.overrides;
if (overrides && overrides.has(view.camera)) {
newView.context?.live?.overrides?.delete(view.camera);
}
return newView;
}; };
export const hasSubstream = (view: View): boolean => { export const hasSubstream = (view: View): boolean => {
const override = view?.context?.live?.overrides?.get(view.camera); return getStreamCameraID(view) !== view.camera;
return !!override && override !== view.camera;
};
export const createViewWithNextStream = (
cameraManager: CameraManager,
view: View,
): View => {
const dependencies = [...getAllDependentCameras(cameraManager, view.camera)];
if (dependencies.length <= 1) {
return view.clone();
}
const newView = view.clone();
const overrides: Map<string, string> = newView.context?.live?.overrides ?? new Map();
const currentOverride = overrides.get(newView.camera) ?? newView.camera;
const currentIndex = dependencies.indexOf(currentOverride);
const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
overrides.set(view.camera, dependencies[newIndex]);
newView.mergeInContext({ live: { overrides: overrides } });
return newView;
}; };
+1 -1
View File
@@ -4,7 +4,7 @@ import {
dispatchFrigateCardErrorEvent, dispatchFrigateCardErrorEvent,
renderProgressIndicator, renderProgressIndicator,
} from '../components/message'; } from '../components/message';
import { CardWideConfig } from '../types'; import { CardWideConfig } from '../config/types';
import { errorToConsole } from './basic'; import { errorToConsole } from './basic';
/** /**
+13 -23
View File
@@ -1,19 +1,18 @@
import { HomeAssistant } from 'custom-card-helpers';
import add from 'date-fns/add'; import add from 'date-fns/add';
import sub from 'date-fns/sub'; import sub from 'date-fns/sub';
import { DataSet } from 'vis-data'; import { DataSet } from 'vis-data';
import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext'; import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext';
import { ClipsOrSnapshotsOrAll } from '../types';
import { CameraManager } from '../camera-manager/manager'; import { CameraManager } from '../camera-manager/manager';
import { EventQuery, RecordingQuery, RecordingSegment } from '../camera-manager/types';
import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/util';
import { ViewMedia } from '../view/media';
import { import {
compressRanges,
ExpiringMemoryRangeSet, ExpiringMemoryRangeSet,
MemoryRangeSet, MemoryRangeSet,
compressRanges,
} from '../camera-manager/range'; } from '../camera-manager/range';
import { errorToConsole, ModifyInterface } from './basic.js'; import { EventQuery, RecordingQuery, RecordingSegment } from '../camera-manager/types';
import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/util';
import { ClipsOrSnapshotsOrAll } from '../types';
import { ViewMedia } from '../view/media';
import { ModifyInterface, errorToConsole } from './basic.js';
// Allow timeline freshness to be at least this number of seconds out of date // Allow timeline freshness to be at least this number of seconds out of date
// (caching times in the data-engine may increase the effective delay). // (caching times in the data-engine may increase the effective delay).
@@ -82,11 +81,11 @@ export class TimelineDataSource {
} }
} }
public async refresh(hass: HomeAssistant, window: TimelineWindow): Promise<void> { public async refresh(window: TimelineWindow): Promise<void> {
try { try {
await Promise.all([ await Promise.all([
this._refreshEvents(hass, window), this._refreshEvents(window),
...(this._showRecordings ? [this._refreshRecordings(hass, window)] : []), ...(this._showRecordings ? [this._refreshRecordings(window)] : []),
]); ]);
} catch (e) { } catch (e) {
errorToConsole(e as Error); errorToConsole(e as Error);
@@ -114,10 +113,7 @@ export class TimelineDataSource {
}); });
} }
protected async _refreshEvents( protected async _refreshEvents(window: TimelineWindow): Promise<void> {
hass: HomeAssistant,
window: TimelineWindow,
): Promise<void> {
if ( if (
this._eventRanges.hasCoverage({ this._eventRanges.hasCoverage({
start: window.start, start: window.start,
@@ -134,7 +130,7 @@ export class TimelineDataSource {
return; return;
} }
const mediaArray = await this._cameraManager.executeMediaQueries(hass, eventQueries); const mediaArray = await this._cameraManager.executeMediaQueries(eventQueries);
const data: FrigateCardTimelineItem[] = []; const data: FrigateCardTimelineItem[] = [];
for (const media of mediaArray ?? []) { for (const media of mediaArray ?? []) {
const startTime = media.getStartTime(); const startTime = media.getStartTime();
@@ -159,10 +155,7 @@ export class TimelineDataSource {
}); });
} }
protected async _refreshRecordings( protected async _refreshRecordings(window: TimelineWindow): Promise<void> {
hass: HomeAssistant,
window: TimelineWindow,
): Promise<void> {
type FrigateCardTimelineItemWithEnd = ModifyInterface< type FrigateCardTimelineItemWithEnd = ModifyInterface<
FrigateCardTimelineItem, FrigateCardTimelineItem,
{ end: number } { end: number }
@@ -228,10 +221,7 @@ export class TimelineDataSource {
if (!recordingQueries) { if (!recordingQueries) {
return; return;
} }
const results = await this._cameraManager.getRecordingSegments( const results = await this._cameraManager.getRecordingSegments(recordingQueries);
hass,
recordingQueries,
);
const newSegments: Map<string, RecordingSegment[]> = new Map(); const newSegments: Map<string, RecordingSegment[]> = new Map();
for (const [query, result] of results) { for (const [query, result] of results) {
+8 -1
View File
@@ -1,9 +1,14 @@
export class Timer { export class Timer {
protected _timer: number | null = null; protected _timer: number | null = null;
protected _repeated = false;
public stop(): void { public stop(): void {
if (this._timer) { if (this._timer) {
window.clearTimeout(this._timer); if (this._repeated) {
window.clearInterval(this._timer);
} else {
window.clearTimeout(this._timer);
}
this._timer = null; this._timer = null;
} }
} }
@@ -18,6 +23,7 @@ export class Timer {
this._timer = null; this._timer = null;
func(); func();
}, seconds * 1000); }, seconds * 1000);
this._repeated = false;
} }
public startRepeated(seconds: number, func: () => void): void { public startRepeated(seconds: number, func: () => void): void {
@@ -25,5 +31,6 @@ export class Timer {
this._timer = window.setInterval(() => { this._timer = window.setInterval(() => {
func(); func();
}, seconds * 1000); }, seconds * 1000);
this._repeated = true;
} }
} }
+2 -10
View File
@@ -62,7 +62,7 @@ export function getParseErrorKeys<T>(error: z.ZodError<T>): string[] {
* @param error The ZodError object from parsing. * @param error The ZodError object from parsing.
* @returns An array of string error paths. * @returns An array of string error paths.
*/ */
export const getParseErrorPaths = <T>(error: z.ZodError<T>): Set<string> | null => { export const getParseErrorPaths = <T>(error: z.ZodError<T>): Set<string> => {
/* Zod errors involving unions are complex, as Zod may not be able to tell /* Zod errors involving unions are complex, as Zod may not be able to tell
* where the 'real' error is vs simply a union option not matching. This * where the 'real' error is vs simply a union option not matching. This
* function finds all ZodError "issues" that don't have an error with 'type' * function finds all ZodError "issues" that don't have an error with 'type'
@@ -79,16 +79,8 @@ export const getParseErrorPaths = <T>(error: z.ZodError<T>): Set<string> | null
if (issue.code === 'invalid_union') { if (issue.code === 'invalid_union') {
const unionErrors = (issue as z.ZodInvalidUnionIssue).unionErrors; const unionErrors = (issue as z.ZodInvalidUnionIssue).unionErrors;
for (const unionError of unionErrors) { for (const unionError of unionErrors) {
const nestedErrors = getParseErrorPaths(unionError); getParseErrorPaths(unionError).forEach(contenders.add, contenders);
if (nestedErrors && nestedErrors.size) {
nestedErrors.forEach(contenders.add, contenders);
}
} }
} else if (issue.code === 'invalid_type') {
if (issue.path[issue.path.length - 1] === 'type') {
return null;
}
contenders.add(getParseErrorPathString(issue.path));
} else { } else {
contenders.add(getParseErrorPathString(issue.path)); contenders.add(getParseErrorPathString(issue.path));
} }
+5 -4
View File
@@ -1,5 +1,6 @@
import { ViewContext } from 'view'; import { ViewContext } from 'view';
import { ClipsOrSnapshots, FrigateCardView, ViewDisplayMode } from '../types.js'; import { FrigateCardView, ViewDisplayMode } from '../config/types.js';
import { ClipsOrSnapshots } from '../types.js';
import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js';
import { MediaQueries } from './media-queries'; import { MediaQueries } from './media-queries';
import { MediaQueriesClassifier } from './media-queries-classifier.js'; import { MediaQueriesClassifier } from './media-queries-classifier.js';
@@ -43,7 +44,7 @@ export class View {
* @param curr The current view. * @param curr The current view.
* @returns True if the view change is a real media change. * @returns True if the view change is a real media change.
*/ */
public static isMajorMediaChange(prev?: View, curr?: View): boolean { public static isMajorMediaChange(prev?: View | null, curr?: View): boolean {
return ( return (
!prev || !prev ||
!curr || !curr ||
@@ -61,7 +62,7 @@ export class View {
); );
} }
public static adoptFromViewIfAppropriate(next: View, curr?: View): void { public static adoptFromViewIfAppropriate(next: View, curr?: View | null): void {
if (!curr) { if (!curr) {
return; return;
} }
@@ -229,7 +230,7 @@ export class View {
* Determine if a view is for the media viewer. * Determine if a view is for the media viewer.
*/ */
public isViewerView(): boolean { public isViewerView(): boolean {
return ['clip', 'snapshot', 'media', 'recording'].includes(this.view); return ['media', 'clip', 'snapshot', 'recording'].includes(this.view);
} }
public supportsMultipleDisplayModes(): boolean { public supportsMultipleDisplayModes(): boolean {
-123
View File
@@ -1,123 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { AutomationsController, AutomationsControllerError } from '../src/automations';
import { ConditionController } from '../src/conditions';
import { automationsSchema, FrigateCardError } from '../src/types';
import { frigateCardHandleAction } from '../src/utils/action.js';
import { createHASS } from './test-utils';
vi.mock('../src/utils/action.js');
describe('AutomationsController', () => {
const actions = [
{
action: 'custom:frigate-card-action',
frigate_card_action: 'clips',
},
];
const conditions = { fullscreen: true };
afterEach(() => {
vi.clearAllMocks();
});
it('should do nothing without automations', () => {
const automationController = new AutomationsController(undefined);
automationController.execute(
mock<HTMLElement>(),
createHASS(),
new ConditionController(),
);
expect(frigateCardHandleAction).not.toBeCalled();
});
it('should execute actions', () => {
const automations = automationsSchema.parse([
{
conditions: conditions,
actions: actions,
},
]);
const automationController = new AutomationsController(automations);
const conditionController = new ConditionController();
const element = mock<HTMLElement>();
const hass = createHASS();
automationController.execute(element, hass, conditionController);
expect(frigateCardHandleAction).not.toBeCalled();
conditionController.setState({ fullscreen: true });
automationController.execute(element, hass, conditionController);
expect(frigateCardHandleAction).toBeCalledTimes(1);
// Automation will not re-fire when condition continues to evaluate the
// same.
automationController.execute(element, hass, conditionController);
expect(frigateCardHandleAction).toBeCalledTimes(1);
conditionController.setState({ fullscreen: false });
automationController.execute(element, hass, conditionController);
expect(frigateCardHandleAction).toBeCalledTimes(1);
conditionController.setState({ fullscreen: true });
automationController.execute(element, hass, conditionController);
expect(frigateCardHandleAction).toBeCalledTimes(2);
});
it('should execute actions_not', () => {
const automations = automationsSchema.parse([
{
conditions: conditions,
actions_not: actions,
},
]);
const automationController = new AutomationsController(automations);
automationController.execute(
mock<HTMLElement>(),
createHASS(),
new ConditionController(),
);
expect(frigateCardHandleAction).toBeCalled();
});
it('should prevent automation loops', () => {
const automations = automationsSchema.parse([
{
conditions: { fullscreen: true },
actions: actions,
},
{
conditions: { fullscreen: false },
actions: actions,
},
]);
const automationController = new AutomationsController(automations);
const conditionController = new ConditionController();
const element = mock<HTMLElement>();
const hass = createHASS();
// Create a setup where one automation action causes another...
let fullscreen = true;
vi.mocked(frigateCardHandleAction).mockImplementation(() => {
fullscreen = !fullscreen;
conditionController.setState({ fullscreen: fullscreen });
automationController.execute(element, hass, conditionController);
});
conditionController.setState({ fullscreen: fullscreen });
expect(() =>
automationController.execute(element, hass, conditionController),
).toThrowError(/Too many nested automation calls/);
expect(frigateCardHandleAction).toBeCalledTimes(10);
});
it('should be able to construct error', () => {
const error = new AutomationsControllerError('message');
expect(error).toBeTruthy();
expect(error instanceof FrigateCardError).toBeTruthy();
});
});
+8 -15
View File
@@ -4,11 +4,16 @@ import { FrigateCameraManagerEngine } from '../../src/camera-manager/frigate/eng
import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic'; import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic';
import { MotionEyeCameraManagerEngine } from '../../src/camera-manager/motioneye/engine-motioneye'; import { MotionEyeCameraManagerEngine } from '../../src/camera-manager/motioneye/engine-motioneye';
import { Engine } from '../../src/camera-manager/types.js'; import { Engine } from '../../src/camera-manager/types.js';
import { CardWideConfig } from '../../src/types.js'; import { CardWideConfig } from '../../src/config/types.js';
import { EntityRegistryManager } from '../../src/utils/ha/entity-registry'; import { EntityRegistryManager } from '../../src/utils/ha/entity-registry';
import { EntityCache } from '../../src/utils/ha/entity-registry/cache'; import { EntityCache } from '../../src/utils/ha/entity-registry/cache';
import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media'; import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media';
import { createCameraConfig, createHASS, createRegistryEntity } from '../test-utils'; import {
createCameraConfig,
createHASS,
createRegistryEntity,
createStateEntity,
} from '../test-utils';
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');
@@ -21,7 +26,6 @@ const createFactory = (options?: {
return new CameraManagerEngineFactory( return new CameraManagerEngineFactory(
options?.entityRegistryManager ?? new EntityRegistryManager(new EntityCache()), options?.entityRegistryManager ?? new EntityRegistryManager(new EntityCache()),
options?.resolvedMediaCache ?? new ResolvedMediaCache(), options?.resolvedMediaCache ?? new ResolvedMediaCache(),
options?.cardWideConfig ?? {},
); );
}; };
@@ -131,18 +135,7 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
entityRegistryManager: entityRegistryManager, entityRegistryManager: entityRegistryManager,
}).getEngineForCamera( }).getEngineForCamera(
createHASS({ createHASS({
'camera.foo': { 'camera.foo': createStateEntity(),
entity_id: 'camera.foo',
state: 'streaming',
last_changed: 'bar',
last_updated: 'baz',
attributes: {},
context: {
id: 'context',
user_id: null,
parent_id: null,
},
},
}), }),
config, config,
), ),
@@ -6,13 +6,12 @@ import {
FrigateRecordingViewMedia, FrigateRecordingViewMedia,
} 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 { CameraConfig, RawFrigateCardConfig } from '../../../src/types'; import { CameraConfig, RawFrigateCardConfig } from '../../../src/config/types';
import { ViewMedia } from '../../../src/view/media'; import { ViewMedia } from '../../../src/view/media';
import { createCameraConfig, createHASS } from '../../test-utils'; import { createCameraConfig, createHASS } from '../../test-utils';
const createEngine = (): FrigateCameraManagerEngine => { const createEngine = (): FrigateCameraManagerEngine => {
return new FrigateCameraManagerEngine( return new FrigateCameraManagerEngine(
{},
new RecordingSegmentsCache(), new RecordingSegmentsCache(),
new RequestCache(), new RequestCache(),
); );
+1 -1
View File
@@ -9,7 +9,7 @@ import {
getRecordingMediaContentID, getRecordingMediaContentID,
getRecordingTitle, getRecordingTitle,
} from '../../../src/camera-manager/frigate/util'; } from '../../../src/camera-manager/frigate/util';
import { CameraConfig } from '../../../src/types'; import { CameraConfig } from '../../../src/config/types';
import { import {
createCameraConfig, createCameraConfig,
createFrigateEvent, createFrigateEvent,
+157
View File
@@ -0,0 +1,157 @@
import { describe, expect, it } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
import { CameraManagerStore } from '../../src/camera-manager/store.js';
import { Engine } from '../../src/camera-manager/types.js';
import { EntityRegistryManager } from '../../src/utils/ha/entity-registry/index.js';
import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media.js';
import { TestViewMedia, createCameraConfig } from '../test-utils.js';
describe('CameraManagerStore', async () => {
const config_visible = createCameraConfig();
const config_hidden = createCameraConfig({
hide: true,
});
const engineFactory = new CameraManagerEngineFactory(
mock<EntityRegistryManager>(),
mock<ResolvedMediaCache>(),
);
const engineGeneric = await engineFactory.createEngine(Engine.Generic);
const engineFrigate = await engineFactory.createEngine(Engine.Frigate);
const setupStore = async (): Promise<CameraManagerStore> => {
const store = new CameraManagerStore();
store.addCamera('camera-visible', config_visible, engineGeneric);
store.addCamera('camera-hidden', config_hidden, engineFrigate);
return store;
};
it('getCameraConfig', async () => {
const store = await setupStore();
expect(store.getCameraConfig('camera-visible')).toBe(config_visible);
expect(store.getCameraConfig('camera-hidden')).toBe(config_hidden);
expect(store.getCameraConfig('camera-not-exist')).toBeNull();
});
it('hasCameraID', async () => {
const store = await setupStore();
expect(store.hasCameraID('camera-visible')).toBeTruthy();
expect(store.hasCameraID('camera-hidden')).toBeTruthy();
});
it('hasVisibleCameraID', async () => {
const store = await setupStore();
expect(store.hasVisibleCameraID('camera-visible')).toBeTruthy();
expect(store.hasVisibleCameraID('camera-hidden')).toBeFalsy();
});
it('getCameraCount', async () => {
const store = await setupStore();
expect(store.getCameraCount()).toBe(2);
});
it('getVisibleCameraCount', async () => {
const store = await setupStore();
expect(store.getVisibleCameraCount()).toBe(1);
});
it('getCameras', async () => {
const store = await setupStore();
expect(store.getCameras()).toEqual(
new Map([
['camera-visible', config_visible],
['camera-hidden', config_hidden],
]),
);
});
it('getVisibleCameras', async () => {
const store = await setupStore();
expect(store.getVisibleCameras()).toEqual(
new Map([['camera-visible', config_visible]]),
);
});
it('getCameraIDs', async () => {
const store = await setupStore();
expect(store.getCameraIDs()).toEqual(new Set(['camera-visible', 'camera-hidden']));
});
it('getVisibleCameraIDs', async () => {
const store = await setupStore();
expect(store.getVisibleCameraIDs()).toEqual(new Set(['camera-visible']));
});
it('reset', async () => {
const store = await setupStore();
store.reset();
expect(store.getCameraCount()).toBe(0);
expect(store.getVisibleCameraCount()).toBe(0);
});
it('getCameraConfigForMedia', async () => {
const store = await setupStore();
const media_1 = new TestViewMedia({ cameraID: 'camera-visible' });
expect(store.getCameraConfigForMedia(media_1)).toBe(config_visible);
const media_2 = new TestViewMedia({ cameraID: 'camera-not-exist' });
expect(store.getCameraConfigForMedia(media_2)).toBeNull();
});
it('getEngineOfType', async () => {
const store = await setupStore();
expect(store.getEngineOfType(Engine.Generic)).toBe(engineGeneric);
expect(store.getEngineOfType(Engine.Frigate)).toBe(engineFrigate);
expect(store.getEngineOfType(Engine.MotionEye)).toBeNull();
});
it('getEngineForCameraID', async () => {
const store = await setupStore();
expect(store.getEngineForCameraID('camera-visible')).toBe(engineGeneric);
expect(store.getEngineForCameraID('camera-hidden')).toBe(engineFrigate);
expect(store.getEngineForCameraID('camera-not-exist')).toBeNull();
});
describe('getEnginesForCameraIDs', async () => {
it('empty input', async () => {
const store = await setupStore();
expect(store.getEnginesForCameraIDs(new Set())).toBeNull();
});
it('multiple cameras', async () => {
const store = await setupStore();
store.addCamera('camera-visible2', config_visible, engineGeneric);
expect(
store.getEnginesForCameraIDs(
new Set([
'camera-visible',
'camera-visible2',
'camera-hidden',
'camera-not-exist',
]),
),
).toEqual(
new Map([
[engineGeneric, new Set(['camera-visible', 'camera-visible2'])],
[engineFrigate, new Set(['camera-hidden'])],
]),
);
});
});
it('getEngineForMedia', async () => {
const store = await setupStore();
const media = new TestViewMedia({ cameraID: 'camera-visible' });
expect(store.getEngineForMedia(media)).toBe(engineGeneric);
});
it('getAllEngines', async () => {
const store = await setupStore();
expect(store.getAllEngines()).toEqual([engineGeneric, engineFrigate]);
});
});
+1 -2
View File
@@ -5,8 +5,7 @@ import {
getCameraEntityFromConfig, getCameraEntityFromConfig,
sortMedia, sortMedia,
} from '../../src/camera-manager/util.js'; } from '../../src/camera-manager/util.js';
import { CameraConfig, cameraConfigSchema } from '../../src/types.js'; import { CameraConfig, cameraConfigSchema } from '../../src/config/types.js';
import { ViewMedia, ViewMediaType } from '../../src/view/media.js';
import { TestViewMedia } from '../test-utils.js'; import { TestViewMedia } from '../test-utils.js';
describe('convertRangeToCacheFriendlyTimes', () => { describe('convertRangeToCacheFriendlyTimes', () => {
@@ -0,0 +1,728 @@
import { afterAll, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import {
ActionType,
FrigateCardCustomAction,
FrigateCardView,
frigateCardCustomActionSchema,
} from '../../src/config/types';
import { FrigateCardMediaPlayer } from '../../src/types';
import {
convertActionToFrigateCardCustomAction,
frigateCardHandleActionConfig,
getActionConfigGivenAction,
} from '../../src/utils/action.js';
import { ActionsManager } from '../../src/card-controller/actions-manager';
import {
createCardAPI,
createConfig,
createHASS,
createMediaLoadedInfo,
createView,
createViewWithMedia,
} from '../test-utils';
vi.mock('../../src/utils/action.js');
vi.mock('../../src/camera-manager/manager.js');
const createAction = (
action: Record<string, unknown>,
): FrigateCardCustomAction | null => {
const result = frigateCardCustomActionSchema.safeParse({
action: 'custom:frigate-card-action',
...action,
});
return result.success ? result.data : null;
};
describe('ActionsManager.getMergedActions', () => {
const config = {
view: {
actions: {
tap_action: {
action: 'navigate',
navigation_path: '1',
},
},
},
live: {
actions: {
tap_action: {
action: 'navigate',
navigation_path: '2',
},
},
},
media_gallery: {
actions: {
tap_action: {
action: 'navigate',
navigation_path: '3',
},
},
},
media_viewer: {
actions: {
tap_action: {
action: 'navigate',
navigation_path: '4',
},
},
},
image: {
actions: {
tap_action: {
action: 'navigate',
navigation_path: '5',
},
},
},
};
afterAll(() => {
vi.restoreAllMocks();
});
it('should get no merged actions with a message', () => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({ view: 'live' }),
);
vi.mocked(api.getMessageManager().hasMessage).mockReturnValue(true);
const manager = new ActionsManager(api);
expect(manager.getMergedActions()).toEqual({});
});
describe('should get merged actions with live view', () => {
it.each([
[
'live' as const,
{
tap_action: {
action: 'navigate',
navigation_path: '2',
},
},
],
[
'clips' as const,
{
tap_action: {
action: 'navigate',
navigation_path: '3',
},
},
],
[
'clip' as const,
{
tap_action: {
action: 'navigate',
navigation_path: '4',
},
},
],
[
'image' as const,
{
tap_action: {
action: 'navigate',
navigation_path: '5',
},
},
],
['timeline' as const, {}],
])('%s', (viewName: FrigateCardView, result: Record<string, unknown>) => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({ view: viewName }),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig(config));
const manager = new ActionsManager(api);
expect(manager.getMergedActions()).toEqual(result);
});
});
});
// @vitest-environment jsdom
describe('ActionsManager.handleInteraction', () => {
it('should handle interaction', () => {
const api = createCardAPI();
const element = document.createElement('div');
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
const manager = new ActionsManager(api);
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const actionForThisInteraction: ActionType = {
action: 'none',
};
vi.mocked(getActionConfigGivenAction).mockReturnValue(actionForThisInteraction);
manager.handleInteraction('tap');
expect(frigateCardHandleActionConfig).toBeCalledWith(
element,
hass,
manager.getMergedActions(),
'tap',
actionForThisInteraction,
);
});
it('should not handle interaction', () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null);
// No values of hass.
manager.handleInteraction('tap');
expect(frigateCardHandleActionConfig).not.toBeCalledWith();
});
});
describe('ActionsManager.handleActionEvent', () => {
it('should handle event', () => {
const action = createAction({ frigate_card_action: 'default' })!;
const event: CustomEvent<FrigateCardCustomAction> = new CustomEvent('ll-custom', {
detail: action,
});
// The file containing convertActionToFrigateCardCustomAction (action.ts) is
// mocked, so need to provide a value here.
vi.mocked(convertActionToFrigateCardCustomAction).mockReturnValue(action);
const api = createCardAPI();
const manager = new ActionsManager(api);
manager.handleActionEvent(event);
expect(api.getViewManager().setViewDefault).toBeCalled();
});
it('should not handle event without detail', () => {
const action = createAction({ frigate_card_action: 'default' })!;
const event = new Event('ll-custom');
// Mock this out just so that if the sentinel in handleActionEvent failed,
// it would still trigger a test failure below.
vi.mocked(convertActionToFrigateCardCustomAction).mockReturnValue(action);
const api = createCardAPI();
const manager = new ActionsManager(api);
manager.handleActionEvent(event);
expect(api.getViewManager().setViewDefault).not.toBeCalled();
});
it('should not handle malformed action', () => {
const action = createAction({ frigate_card_action: 'default' })!;
const event: CustomEvent<FrigateCardCustomAction> = new CustomEvent('ll-custom', {
detail: action,
});
vi.mocked(convertActionToFrigateCardCustomAction).mockReturnValue(null);
const api = createCardAPI();
const manager = new ActionsManager(api);
manager.handleActionEvent(event);
expect(api.getViewManager().setViewDefault).not.toBeCalled();
});
});
describe('ActionsManager.executeAction', () => {
it('should not handle actions with different card_id', async () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
card_id: 'foo',
}),
);
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
card_id: 'NOT_foo',
frigate_card_action: 'default',
})!,
);
expect(api.getViewManager().setViewDefault).not.toBeCalled();
});
it('should handle default action', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
frigate_card_action: 'default',
})!,
);
expect(api.getViewManager().setViewDefault).toBeCalled();
});
describe('should handle view action', async () => {
it.each([
['clip' as const],
['clips' as const],
['image' as const],
['live' as const],
['recording' as const],
['recordings' as const],
['snapshot' as const],
['snapshots' as const],
['timeline' as const],
])('%s', async (viewName: FrigateCardView) => {
const api = createCardAPI();
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
frigate_card_action: viewName,
})!,
);
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
expect.objectContaining({
viewName: viewName,
}),
);
});
});
it('should handle download action', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
frigate_card_action: 'download',
})!,
);
expect(api.getDownloadManager().downloadViewerMedia).toBeCalled();
});
it('should handle camera ui action', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
frigate_card_action: 'camera_ui',
})!,
);
expect(api.getCameraURLManager().openURL).toBeCalled();
});
it('should handle expand action', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
frigate_card_action: 'expand',
})!,
);
expect(api.getExpandManager().toggleExpanded).toBeCalled();
});
it('should handle fullscreen action', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
frigate_card_action: 'fullscreen',
})!,
);
expect(api.getFullscreenManager().toggleFullscreen).toBeCalled();
});
it('should handle menu toggle action', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
frigate_card_action: 'menu_toggle',
})!,
);
expect(api.getCardElementManager().toggleMenu).toBeCalled();
});
describe('should handle camera_select action', () => {
it('with valid camera and view', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true);
await manager.executeAction(
createAction({
frigate_card_action: 'camera_select',
camera: 'camera',
})!,
);
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
expect.objectContaining({
viewName: 'live',
cameraID: 'camera',
}),
);
});
it('without config', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null);
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({
view: 'timeline',
}),
);
vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true);
await manager.executeAction(
createAction({
frigate_card_action: 'camera_select',
camera: 'camera',
})!,
);
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
expect.objectContaining({
viewName: 'timeline',
cameraID: 'camera',
}),
);
});
it('with target view', async () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
// Change to clips view when the camera changes.
camera_select: 'clips',
},
}),
);
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({
view: 'live',
}),
);
vi.mocked(api.getViewManager().isViewSupportedByCamera).mockReturnValue(true);
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
frigate_card_action: 'camera_select',
camera: 'camera',
})!,
);
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
expect.objectContaining({
viewName: 'clips',
cameraID: 'camera',
}),
);
});
it('without a current view', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
frigate_card_action: 'camera_select',
camera: 'camera',
})!,
);
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
});
it('with an unsupported view', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({
view: 'timeline',
}),
);
await manager.executeAction(
createAction({
frigate_card_action: 'camera_select',
camera: 'camera',
})!,
);
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
expect.objectContaining({
// Should have fallen back to the default view.
viewName: 'live',
cameraID: 'camera',
}),
);
});
});
it('should handle live_substream_select action', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
frigate_card_action: 'live_substream_select',
camera: 'substream',
})!,
);
expect(api.getViewManager().setViewWithSubstream).toBeCalledWith('substream');
});
it('should handle live_substream_off action', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
frigate_card_action: 'live_substream_off',
})!,
);
expect(api.getViewManager().setViewWithoutSubstream).toBeCalled();
});
it('should handle live_substream_on action', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
frigate_card_action: 'live_substream_on',
})!,
);
expect(api.getViewManager().setViewWithSubstream).toBeCalledWith();
});
describe('should handle media_player action', () => {
it('to stop', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
frigate_card_action: 'media_player',
media_player_action: 'stop',
media_player: 'this_is_a_media_player',
})!,
);
expect(api.getMediaPlayerManager().stop).toBeCalledWith('this_is_a_media_player');
});
it('to play live', async () => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({
camera: 'camera',
view: 'live',
}),
);
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
frigate_card_action: 'media_player',
media_player_action: 'play',
media_player: 'this_is_a_media_player',
})!,
);
expect(api.getMediaPlayerManager().playLive).toBeCalledWith(
'this_is_a_media_player',
'camera',
);
});
it('to play media', async () => {
const api = createCardAPI();
const view = createViewWithMedia({
camera: 'camera',
view: 'media',
});
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
frigate_card_action: 'media_player',
media_player_action: 'play',
media_player: 'this_is_a_media_player',
})!,
);
expect(api.getMediaPlayerManager().playMedia).toBeCalledWith(
'this_is_a_media_player',
view.queryResults?.getSelectedResult(),
);
});
it('to play media without selected media', async () => {
const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(
createView({
view: 'media',
}),
);
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
frigate_card_action: 'media_player',
media_player_action: 'play',
media_player: 'this_is_a_media_player',
})!,
);
expect(api.getMediaPlayerManager().playMedia).not.toBeCalled();
});
});
it('should handle diagnostics action', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
frigate_card_action: 'diagnostics',
})!,
);
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
expect.objectContaining({
viewName: 'diagnostics',
}),
);
});
it('should handle microphone_mute action', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
frigate_card_action: 'microphone_mute',
})!,
);
expect(api.getMicrophoneManager().mute).toBeCalled();
});
it('should handle microphone_unmute action', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
frigate_card_action: 'microphone_unmute',
})!,
);
expect(api.getMicrophoneManager().unmute).toBeCalled();
});
describe('should handle media player action', () => {
it.each([
['mute' as const],
['unmute' as const],
['play' as const],
['pause' as const],
])('%s', async (action: 'mute' | 'unmute' | 'play' | 'pause') => {
const api = createCardAPI();
const player = mock<FrigateCardMediaPlayer>();
vi.mocked(api.getMediaLoadedInfoManager().get).mockReturnValue(
createMediaLoadedInfo({
player: player,
}),
);
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
frigate_card_action: action,
})!,
);
expect(player[action]).toBeCalled();
});
});
it('should handle screenshot action', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
frigate_card_action: 'screenshot',
})!,
);
expect(api.getDownloadManager().downloadScreenshot).toBeCalled();
});
it('should handle display_mode_select action', async () => {
const api = createCardAPI();
const manager = new ActionsManager(api);
await manager.executeAction(
createAction({
frigate_card_action: 'display_mode_select',
display_mode: 'grid',
})!,
);
expect(api.getViewManager().setViewWithNewDisplayMode).toBeCalledWith('grid');
});
it('should handle unknown action', async () => {
const manager = new ActionsManager(createCardAPI());
const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => true);
await manager.executeAction(
// Have to manually create the action (vs using `createAction()`) since
// it's malformed.
{
frigate_card_action: 'not_a_real_action',
} as unknown as FrigateCardCustomAction,
);
expect(spy).toBeCalledWith(
'Frigate card received unknown card action: not_a_real_action',
);
});
});
@@ -0,0 +1,73 @@
import add from 'date-fns/add';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AutoUpdateManager } from '../../src/card-controller/auto-update-manager';
import { createCardAPI, createConfig } from '../test-utils';
// @vitest-environment jsdom
describe('AutoUpdateManager', () => {
const start = new Date('2023-09-23T19:12:00');
afterEach(() => {
vi.useRealTimers();
});
it('should set default view when allowed', () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
update_seconds: 10,
},
}),
);
// Card is triggered.
vi.mocked(api.getTriggersManager().isTriggered).mockReturnValue(true);
vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false);
vi.useFakeTimers();
vi.setSystemTime(start);
const manager = new AutoUpdateManager(api);
manager.startDefaultViewTimer();
expect(api.getViewManager().setViewDefault).not.toBeCalled();
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
expect(api.getViewManager().setViewDefault).not.toBeCalled();
vi.mocked(api.getTriggersManager().isTriggered).mockReturnValue(false);
vi.setSystemTime(add(start, { seconds: 20 }));
vi.runOnlyPendingTimers();
expect(api.getViewManager().setViewDefault).toBeCalled();
});
it('should not set default view when not configured', () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
update_seconds: 0,
},
}),
);
vi.mocked(api.getTriggersManager().isTriggered).mockReturnValue(false);
vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false);
vi.useFakeTimers();
vi.setSystemTime(start);
const manager = new AutoUpdateManager(api);
manager.startDefaultViewTimer();
expect(api.getViewManager().setViewDefault).not.toBeCalled();
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
expect(api.getViewManager().setViewDefault).not.toBeCalled();
});
});
@@ -0,0 +1,150 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { frigateCardHandleAction } from '../../src/utils/action.js';
import {
AutomationsManager,
} from '../../src/card-controller/automations-manager.js';
import { createCardAPI, createConfig, createHASS } from '../test-utils.js';
vi.mock('../../src/utils/action.js');
describe('AutomationsManager', () => {
const actions = [
{
action: 'custom:frigate-card-action',
frigate_card_action: 'clips',
},
];
const conditions = { fullscreen: true };
afterEach(() => {
vi.clearAllMocks();
});
it('should do nothing without hass', () => {
const api = createCardAPI();
const automationsManager = new AutomationsManager(api);
automationsManager.execute();
expect(frigateCardHandleAction).not.toBeCalled();
});
it('should do nothing without automations', () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const automationsManager = new AutomationsManager(api);
automationsManager.setAutomationsFromConfig();
automationsManager.execute();
expect(frigateCardHandleAction).not.toBeCalled();
});
it('should execute actions', () => {
const config = createConfig({
automations: [
{
conditions: conditions,
actions: actions,
},
],
});
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(api.getConfigManager().getNonOverriddenConfig).mockReturnValue(config);
const automationsManager = new AutomationsManager(api);
automationsManager.setAutomationsFromConfig();
automationsManager.execute();
expect(frigateCardHandleAction).not.toBeCalled();
vi.mocked(api.getConditionsManager().evaluateCondition).mockReturnValue(true);
automationsManager.execute();
expect(frigateCardHandleAction).toBeCalledTimes(1);
// Automation will not re-fire when condition continues to evaluate the
// same.
automationsManager.execute();
expect(frigateCardHandleAction).toBeCalledTimes(1);
vi.mocked(api.getConditionsManager().evaluateCondition).mockReturnValue(false);
automationsManager.execute();
expect(frigateCardHandleAction).toBeCalledTimes(1);
vi.mocked(api.getConditionsManager().evaluateCondition).mockReturnValue(true);
automationsManager.execute();
expect(frigateCardHandleAction).toBeCalledTimes(2);
});
it('should execute actions_not', () => {
const config = createConfig({
automations: [
{
conditions: conditions,
actions_not: actions,
},
],
});
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(api.getConfigManager().getNonOverriddenConfig).mockReturnValue(config);
vi.mocked(api.getConditionsManager().evaluateCondition).mockReturnValue(false);
const automationsManager = new AutomationsManager(api);
automationsManager.setAutomationsFromConfig();
automationsManager.execute();
expect(frigateCardHandleAction).toBeCalled();
});
it('should prevent automation loops', () => {
const config = createConfig({
automations: [
{
conditions: { fullscreen: true },
actions: actions,
},
{
conditions: { fullscreen: true },
actions_not: actions,
},
],
});
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(api.getConfigManager().getNonOverriddenConfig).mockReturnValue(config);
const automationsManager = new AutomationsManager(api);
automationsManager.setAutomationsFromConfig();
// Create a setup where one automation action causes another...
let evaluation = true;
vi.mocked(frigateCardHandleAction).mockImplementation(() => {
evaluation = !evaluation;
vi.mocked(api.getConditionsManager().evaluateCondition).mockReturnValue(
evaluation,
);
automationsManager.execute();
});
vi.mocked(api.getConditionsManager().evaluateCondition).mockReturnValue(evaluation);
automationsManager.execute();
expect(api.getMessageManager().setMessageIfHigherPriority).toBeCalledWith(
expect.objectContaining({
type: 'error',
message:
'Too many nested automation calls, please check your configuration for loops',
}),
);
expect(frigateCardHandleAction).toBeCalledTimes(10);
});
});

Some files were not shown because too many files have changed in this diff Show More