Add out-of-the-box Frigate PTZ support.

This commit is contained in:
Dermot Duffy
2023-10-29 14:10:41 -07:00
parent fa43d2e439
commit e12912fa85
74 changed files with 3981 additions and 1514 deletions
+8 -1
View File
@@ -32,6 +32,7 @@ class ActionHandler extends HTMLElement implements ActionHandler {
protected doubleClickTimer = new Timer();
protected held = false;
protected started = false;
public connectedCallback(): void {
[
@@ -83,7 +84,12 @@ class ActionHandler extends HTMLElement implements ActionHandler {
this.held = true;
});
fireEvent(element, 'action', { action: 'start_tap' });
// Without this check we get double start_tap events from touchstart and
// mousedown events (on Android).
if (!this.started) {
this.started = true;
fireEvent(element, 'action', { action: 'start_tap' });
}
};
const end = (ev: Event): void => {
@@ -105,6 +111,7 @@ class ActionHandler extends HTMLElement implements ActionHandler {
this.holdTimer.stop();
this.started = false;
fireEvent(element, 'action', { action: 'end_tap' });
if (options?.hasHold && this.held) {
@@ -15,14 +15,14 @@ import { Entity } from '../../utils/ha/entity-registry/types';
import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media';
import { ViewMedia } from '../../view/media';
import { RequestCache } from '../cache';
import { Camera } from '../camera';
import { CameraManagerEngine } from '../engine';
import { CameraInitializationError } from '../error';
import { GenericCameraManagerEngine } from '../generic/engine-generic';
import { rangesOverlap } from '../range';
import { CameraManagerReadOnlyConfigStore } from '../store';
import {
CameraConfigs,
CameraEndpoint,
CameraManagerCameraCapabilities,
CameraManagerMediaCapabilities,
DataQuery,
EventQuery,
@@ -141,7 +141,7 @@ export class BrowseMediaCameraManagerEngine
hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager,
cameraConfig: CameraConfig,
): Promise<CameraConfig> {
): Promise<Camera> {
const entity = cameraConfig.camera_entity
? await entityRegistryManager.getEntity(hass, cameraConfig.camera_entity)
: null;
@@ -152,11 +152,20 @@ export class BrowseMediaCameraManagerEngine
);
}
this._cameraEntities.set(cameraConfig.camera_entity, entity);
return cameraConfig;
return new Camera(cameraConfig, this, {
canFavoriteEvents: false,
canFavoriteRecordings: false,
canSeek: false,
supportsClips: true,
supportsRecordings: false,
supportsSnapshots: true,
supportsTimeline: true,
});
}
public generateDefaultEventQuery(
_cameras: CameraConfigs,
_store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
query: PartialEventQuery,
): EventQuery[] | null {
@@ -191,21 +200,6 @@ export class BrowseMediaCameraManagerEngine
return null;
}
public getCameraCapabilities(
cameraConfig: CameraConfig,
): CameraManagerCameraCapabilities | null {
const parentCapabilities = super.getCameraCapabilities(cameraConfig);
if (!parentCapabilities) {
return null;
}
return {
...parentCapabilities,
supportsClips: true,
supportsSnapshots: true,
supportsTimeline: true,
};
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities {
return {
+44
View File
@@ -0,0 +1,44 @@
import { CameraConfig } from '../config/types';
import { localize } from '../localize/localize';
import { CameraManagerEngine } from './engine';
import { CameraNoIDError } from './error';
import { CameraManagerCameraCapabilities } from './types';
export class Camera {
protected _config: CameraConfig;
protected _engine: CameraManagerEngine;
protected _capabilities: CameraManagerCameraCapabilities;
constructor(
config: CameraConfig,
engine: CameraManagerEngine,
capabilities: CameraManagerCameraCapabilities,
) {
this._config = config;
this._engine = engine;
this._capabilities = capabilities;
}
public getConfig(): CameraConfig {
return this._config;
}
public setID(cameraID: string): void {
this._config.id = cameraID;
}
public getID(): string {
if (this._config.id) {
return this._config.id;
}
throw new CameraNoIDError(localize('error.no_camera_id'));
}
public getEngine(): CameraManagerEngine {
return this._engine;
}
public getCapabilities(): CameraManagerCameraCapabilities {
return this._capabilities;
}
}
+28 -18
View File
@@ -1,14 +1,18 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { CameraConfig } from '../config/types';
import {
CameraConfig,
PTZAction,
PTZPhase,
} from '../config/types';
import { ExtendedHomeAssistant } from '../types';
import { EntityRegistryManager } from '../utils/ha/entity-registry';
import { ViewMedia } from '../view/media';
import { Camera } from './camera';
import { CameraManagerReadOnlyConfigStore } from './store';
import {
CameraConfigs,
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraManagerCameraCapabilities,
CameraManagerCameraMetadata,
CameraManagerMediaCapabilities,
DataQuery,
@@ -37,57 +41,57 @@ export interface CameraManagerEngine {
hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager,
cameraConfig: CameraConfig,
): Promise<CameraConfig>;
): Promise<Camera>;
generateDefaultEventQuery(
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
query: PartialEventQuery,
): EventQuery[] | null;
generateDefaultRecordingQuery(
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
query: PartialRecordingQuery,
): RecordingQuery[] | null;
generateDefaultRecordingSegmentsQuery(
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
query: PartialRecordingSegmentsQuery,
): RecordingSegmentsQuery[] | null;
getEvents(
hass: HomeAssistant,
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
query: EventQuery,
engineOptions?: EngineOptions,
): Promise<EventQueryResultsMap | null>;
getRecordings(
hass: HomeAssistant,
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
query: RecordingQuery,
engineOptions?: EngineOptions,
): Promise<RecordingQueryResultsMap | null>;
getRecordingSegments(
hass: HomeAssistant,
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
query: RecordingSegmentsQuery,
engineOptions?: EngineOptions,
): Promise<RecordingSegmentsQueryResultsMap | null>;
generateMediaFromEvents(
hass: HomeAssistant,
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
query: EventQuery,
results: QueryReturnType<EventQuery>,
): ViewMedia[] | null;
generateMediaFromRecordings(
hass: HomeAssistant,
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
query: RecordingQuery,
results: QueryReturnType<RecordingQuery>,
): ViewMedia[] | null;
@@ -109,7 +113,7 @@ export interface CameraManagerEngine {
getMediaSeekTime(
hass: HomeAssistant,
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
media: ViewMedia,
target: Date,
engineOptions?: EngineOptions,
@@ -117,7 +121,7 @@ export interface CameraManagerEngine {
getMediaMetadata(
hass: HomeAssistant,
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
query: MediaMetadataQuery,
engineOptions?: EngineOptions,
): Promise<MediaMetadataQueryResultsMap | null>;
@@ -127,14 +131,20 @@ export interface CameraManagerEngine {
cameraConfig: CameraConfig,
): CameraManagerCameraMetadata;
getCameraCapabilities(
cameraConfig: CameraConfig,
): CameraManagerCameraCapabilities | null;
getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null;
getCameraEndpoints(
cameraConfig: CameraConfig,
context?: CameraEndpointsContext,
): CameraEndpoints | null;
executePTZAction(
hass: HomeAssistant,
cameraConfig: CameraConfig,
action: PTZAction,
options?: {
phase?: PTZPhase,
preset?: string,
}
): Promise<void>;
}
+1
View File
@@ -1,3 +1,4 @@
import { FrigateCardError } from '../types.js';
export class CameraInitializationError extends FrigateCardError {}
export class CameraNoIDError extends FrigateCardError {}
+142 -63
View File
@@ -9,11 +9,12 @@ 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 { CameraConfig, PTZAction, PTZPhase } from '../../config/types';
import { localize } from '../../localize/localize';
import { ExtendedHomeAssistant } from '../../types';
import {
allPromises,
errorToConsole,
formatDate,
prettifyTitle,
runWhenIdleIfSupported,
@@ -24,6 +25,7 @@ import { Entity } from '../../utils/ha/entity-registry/types';
import { ViewMedia } from '../../view/media';
import { ViewMediaClassifier } from '../../view/media-classifier';
import { RecordingSegmentsCache, RequestCache } from '../cache';
import { Camera } from '../camera';
import {
CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
CameraManagerEngine,
@@ -31,12 +33,11 @@ import {
import { CameraInitializationError } from '../error';
import { GenericCameraManagerEngine } from '../generic/engine-generic';
import { DateRange } from '../range';
import { CameraManagerReadOnlyConfigStore } from '../store';
import {
CameraConfigs,
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraManagerCameraCapabilities,
CameraManagerCameraMetadata,
CameraManagerMediaCapabilities,
DataQuery,
@@ -51,6 +52,8 @@ import {
PartialEventQuery,
PartialRecordingQuery,
PartialRecordingSegmentsQuery,
PTZCapabilities,
PTZMovementType,
QueryResults,
QueryResultsType,
QueryReturnType,
@@ -74,12 +77,14 @@ import {
getRecordingSegments,
getRecordingsSummary,
retainEvent,
getPTZInfo,
} from './requests';
import {
FrigateEventQueryResults,
FrigateRecording,
FrigateRecordingQueryResults,
FrigateRecordingSegmentsQueryResults,
PTZInfo,
} from './types';
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
@@ -144,7 +149,7 @@ export class FrigateCameraManagerEngine
hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager,
cameraConfig: CameraConfig,
): Promise<CameraConfig> {
): Promise<Camera> {
const hasCameraName = !!cameraConfig.frigate?.camera_name;
const hasAutoTriggers =
cameraConfig.triggers.motion || cameraConfig.triggers.occupancy;
@@ -207,7 +212,64 @@ export class FrigateCameraManagerEngine
cameraConfig.triggers.entities = uniq(cameraConfig.triggers.entities);
}
return cameraConfig;
const ptz = await this._getPTZCapabilities(hass, cameraConfig);
const isBirdseye = this._isBirdseye(cameraConfig);
return new Camera(cameraConfig, this, {
canFavoriteEvents: !isBirdseye,
canFavoriteRecordings: !isBirdseye,
canSeek: true,
supportsClips: !isBirdseye,
supportsSnapshots: !isBirdseye,
supportsRecordings: !isBirdseye,
supportsTimeline: !isBirdseye,
...(ptz && { ptz: ptz }),
});
}
protected async _getPTZCapabilities(
hass: HomeAssistant,
cameraConfig: CameraConfig,
): Promise<PTZCapabilities | null> {
if (!cameraConfig.frigate.camera_name) {
return null;
}
let ptzInfo: PTZInfo | null = null;
try {
ptzInfo = await getPTZInfo(
hass,
cameraConfig.frigate.client_id,
cameraConfig.frigate.camera_name,
);
} catch (e) {
errorToConsole(e as Error);
return null;
}
const panTilt: PTZMovementType[] = [
...(ptzInfo.features?.includes('pt') ? ['continuous' as const] : []),
...(ptzInfo.features?.includes('pt-r') ? ['relative' as const] : []),
];
const zoom: PTZMovementType[] = [
...(ptzInfo.features?.includes('zoom') ? ['continuous' as const] : []),
...(ptzInfo.features?.includes('zoom-r') ? ['relative' as const] : []),
];
const presets = ptzInfo.presets;
if (panTilt.length || zoom.length || presets?.length) {
return {
...(panTilt && { panTilt: panTilt }),
...(zoom && { zoom: zoom }),
...(presets && { presets: presets }),
};
}
return null;
}
protected _isBirdseye(cameraConfig: CameraConfig): boolean {
return cameraConfig.frigate.camera_name === CAMERA_BIRDSEYE;
}
/**
@@ -330,13 +392,11 @@ export class FrigateCameraManagerEngine
}
public generateDefaultEventQuery(
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
query?: PartialEventQuery,
): EventQuery[] | null {
const relevantCameraConfigs = Array.from(cameraIDs).map((cameraID) =>
cameras.get(cameraID),
);
const relevantCameraConfigs = [...store.getCameraConfigs(cameraIDs)];
// If all cameras specify exactly the same zones or labels (incl. none), we
// can use a single batch query which will be better performance wise,
@@ -365,7 +425,7 @@ export class FrigateCameraManagerEngine
const output: EventQuery[] = [];
for (const cameraID of cameraIDs) {
const cameraConfig = cameras.get(cameraID);
const cameraConfig = store.getCameraConfig(cameraID);
if (cameraConfig) {
output.push({
type: QueryType.Event,
@@ -384,7 +444,7 @@ export class FrigateCameraManagerEngine
}
public generateDefaultRecordingQuery(
_cameras: CameraConfigs,
_store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
query?: PartialRecordingQuery,
): RecordingQuery[] {
@@ -398,7 +458,7 @@ export class FrigateCameraManagerEngine
}
public generateDefaultRecordingSegmentsQuery(
_cameras: CameraConfigs,
_store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
query: PartialRecordingSegmentsQuery,
): RecordingSegmentsQuery[] | null {
@@ -431,12 +491,12 @@ export class FrigateCameraManagerEngine
}
protected _buildInstanceToCameraIDMapFromQuery(
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
): Map<string, Set<string>> {
const output: Map<string, Set<string>> = new Map();
for (const cameraID of cameraIDs) {
const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID);
const cameraConfig = this._getQueryableCameraConfig(store, cameraID);
const clientID = cameraConfig?.frigate.client_id;
if (clientID) {
if (!output.has(clientID)) {
@@ -449,12 +509,12 @@ export class FrigateCameraManagerEngine
}
protected _getFrigateCameraNamesForCameraIDs(
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
cameraIDs: Set<string>,
): Set<string> {
const output = new Set<string>();
for (const cameraID of cameraIDs) {
const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID);
const cameraConfig = this._getQueryableCameraConfig(store, cameraID);
if (cameraConfig?.frigate.camera_name) {
output.add(cameraConfig.frigate.camera_name);
}
@@ -464,7 +524,7 @@ export class FrigateCameraManagerEngine
public async getEvents(
hass: HomeAssistant,
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
query: EventQuery,
engineOptions?: EngineOptions,
): Promise<EventQueryResultsMap | null> {
@@ -487,7 +547,7 @@ export class FrigateCameraManagerEngine
const nativeQuery: NativeFrigateEventQuery = {
instance_id: instanceID,
cameras: Array.from(this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs)),
cameras: Array.from(this._getFrigateCameraNamesForCameraIDs(store, cameraIDs)),
...(query.what && { labels: Array.from(query.what) }),
...(query.where && { zones: Array.from(query.where) }),
...(query.tags && { sub_labels: Array.from(query.tags) }),
@@ -518,10 +578,7 @@ export class FrigateCameraManagerEngine
// Frigate allows multiple cameras to be searched for events in a single
// query. Break them down into groups of cameras per Frigate instance, then
// query once per instance for all cameras in that instance.
const instances = this._buildInstanceToCameraIDMapFromQuery(
cameras,
query.cameraIDs,
);
const instances = this._buildInstanceToCameraIDMapFromQuery(store, query.cameraIDs);
await Promise.all(
Array.from(instances.keys()).map((instanceID) =>
@@ -533,7 +590,7 @@ export class FrigateCameraManagerEngine
public async getRecordings(
hass: HomeAssistant,
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
query: RecordingQuery,
engineOptions?: EngineOptions,
): Promise<RecordingQueryResultsMap | null> {
@@ -551,7 +608,7 @@ export class FrigateCameraManagerEngine
return;
}
const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID);
const cameraConfig = this._getQueryableCameraConfig(store, cameraID);
if (!cameraConfig || !cameraConfig.frigate.camera_name) {
return;
}
@@ -619,7 +676,7 @@ export class FrigateCameraManagerEngine
public async getRecordingSegments(
hass: HomeAssistant,
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
query: RecordingSegmentsQuery,
engineOptions?: EngineOptions,
): Promise<RecordingSegmentsQueryResultsMap | null> {
@@ -630,7 +687,7 @@ export class FrigateCameraManagerEngine
cameraID: string,
): Promise<void> => {
const query = { ...baseQuery, cameraIDs: new Set([cameraID]) };
const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID);
const cameraConfig = this._getQueryableCameraConfig(store, cameraID);
if (!cameraConfig || !cameraConfig.frigate.camera_name) {
return;
}
@@ -687,12 +744,12 @@ export class FrigateCameraManagerEngine
Array.from(query.cameraIDs).map((cameraID) => processQuery(query, cameraID)),
);
runWhenIdleIfSupported(() => this._throttledSegmentGarbageCollector(hass, cameras));
runWhenIdleIfSupported(() => this._throttledSegmentGarbageCollector(hass, store));
return output.size ? output : null;
}
protected _getCameraIDMatch(
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
query: DataQuery,
instanceID: string,
cameraName: string,
@@ -704,7 +761,7 @@ export class FrigateCameraManagerEngine
if (query.cameraIDs.size === 1) {
return [...query.cameraIDs][0];
}
for (const [cameraID, cameraConfig] of cameras.entries()) {
for (const [cameraID, cameraConfig] of store.getCameraConfigEntries()) {
if (
cameraConfig.frigate.client_id === instanceID &&
cameraConfig.frigate.camera_name === cameraName
@@ -717,7 +774,7 @@ export class FrigateCameraManagerEngine
public generateMediaFromEvents(
_hass: HomeAssistant,
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
query: EventQuery,
results: QueryReturnType<EventQuery>,
): ViewMedia[] | null {
@@ -728,7 +785,7 @@ export class FrigateCameraManagerEngine
const output: ViewMedia[] = [];
for (const event of results.events) {
const cameraID = this._getCameraIDMatch(
cameras,
store,
query,
results.instanceID,
event.camera,
@@ -736,7 +793,7 @@ export class FrigateCameraManagerEngine
if (!cameraID) {
continue;
}
const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID);
const cameraConfig = this._getQueryableCameraConfig(store, cameraID);
if (!cameraConfig) {
continue;
}
@@ -771,7 +828,7 @@ export class FrigateCameraManagerEngine
public generateMediaFromRecordings(
hass: HomeAssistant,
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
_query: RecordingQuery,
results: QueryReturnType<RecordingQuery>,
): ViewMedia[] | null {
@@ -781,7 +838,7 @@ export class FrigateCameraManagerEngine
const output: ViewMedia[] = [];
for (const recording of results.recordings) {
const cameraConfig = this._getQueryableCameraConfig(cameras, recording.cameraID);
const cameraConfig = this._getQueryableCameraConfig(store, recording.cameraID);
if (!cameraConfig) {
continue;
}
@@ -809,7 +866,7 @@ export class FrigateCameraManagerEngine
public async getMediaSeekTime(
hass: HomeAssistant,
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
media: ViewMedia,
target: Date,
engineOptions?: EngineOptions,
@@ -828,7 +885,7 @@ export class FrigateCameraManagerEngine
type: QueryType.RecordingSegments,
};
const results = await this.getRecordingSegments(hass, cameras, query, engineOptions);
const results = await this.getRecordingSegments(hass, store, query, engineOptions);
if (results) {
return this._getSeekTimeInSegments(
@@ -843,11 +900,11 @@ export class FrigateCameraManagerEngine
}
protected _getQueryableCameraConfig(
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
cameraID: string,
): CameraConfig | null {
const cameraConfig = cameras.get(cameraID);
if (!cameraConfig || cameraConfig.frigate.camera_name == CAMERA_BIRDSEYE) {
const cameraConfig = store.getCameraConfig(cameraID);
if (!cameraConfig || this._isBirdseye(cameraConfig)) {
return null;
}
return cameraConfig;
@@ -865,7 +922,7 @@ export class FrigateCameraManagerEngine
public async getMediaMetadata(
hass: HomeAssistant,
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
query: MediaMetadataQuery,
engineOptions?: EngineOptions,
): Promise<MediaMetadataQueryResultsMap | null> {
@@ -885,16 +942,13 @@ export class FrigateCameraManagerEngine
const days: Set<string> = new Set();
const tags: Set<string> = new Set();
const instances = this._buildInstanceToCameraIDMapFromQuery(
cameras,
query.cameraIDs,
);
const instances = this._buildInstanceToCameraIDMapFromQuery(store, query.cameraIDs);
const processEventSummary = async (
instanceID: string,
cameraIDs: Set<string>,
): Promise<void> => {
const cameraNames = this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs);
const cameraNames = this._getFrigateCameraNamesForCameraIDs(store, cameraIDs);
for (const entry of await getEventSummary(hass, instanceID)) {
if (!cameraNames.has(entry.camera)) {
// If this entry applies to a camera that *is* in this Frigate
@@ -919,7 +973,7 @@ export class FrigateCameraManagerEngine
const processRecordings = async (cameraIDs: Set<string>): Promise<void> => {
const recordings = await this.getRecordings(
hass,
cameras,
store,
{
type: QueryType.Recording,
cameraIDs: cameraIDs,
@@ -977,7 +1031,7 @@ export class FrigateCameraManagerEngine
*/
protected async _garbageCollectSegments(
hass: HomeAssistant,
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
): Promise<void> {
const cameraIDs = this._recordingSegmentsCache.getCameraIDs();
const recordingQuery: RecordingQuery = {
@@ -992,7 +1046,7 @@ export class FrigateCameraManagerEngine
return `${cameraID}/${startTime.getDate()}/${startTime.getHours()}`;
};
const results = await this.getRecordings(hass, cameras, recordingQuery);
const results = await this.getRecordings(hass, store, recordingQuery);
if (!results) {
return;
}
@@ -1053,21 +1107,6 @@ export class FrigateCameraManagerEngine
return seekMilliseconds / 1000;
}
public getCameraCapabilities(
cameraConfig: CameraConfig,
): CameraManagerCameraCapabilities {
const isBirdseye = cameraConfig.frigate.camera_name === CAMERA_BIRDSEYE;
return {
canFavoriteEvents: !isBirdseye,
canFavoriteRecordings: !isBirdseye,
canSeek: true,
supportsClips: !isBirdseye,
supportsSnapshots: !isBirdseye,
supportsRecordings: !isBirdseye,
supportsTimeline: !isBirdseye,
};
}
public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities {
return {
canFavorite: ViewMediaClassifier.isEvent(media),
@@ -1189,4 +1228,44 @@ export class FrigateCameraManagerEngine
...(webrtcCard && { webrtcCard: webrtcCard }),
};
}
public async executePTZAction(
hass: HomeAssistant,
cameraConfig: CameraConfig,
action: PTZAction,
options?: {
phase?: PTZPhase;
preset?: string;
},
): Promise<void> {
const cameraEntity = cameraConfig.camera_entity;
if (action === 'preset' && !options?.preset) {
return;
}
// Awkward translation between card action and service parameters:
// https://github.com/blakeblackshear/frigate-hass-integration/blob/dev/custom_components/frigate/services.yaml
await hass.callService('frigate', 'ptz', {
entity_id: cameraEntity,
action:
options?.phase === 'stop'
? 'stop'
: action === 'zoom_in' || action === 'zoom_out'
? 'zoom'
: action === 'preset'
? 'preset'
: 'move',
...(options?.phase !== 'stop' && {
argument:
action === 'zoom_in'
? 'in'
: action === 'zoom_out'
? 'out'
: action === 'preset'
? options?.preset
: action,
}),
});
}
}
+19
View File
@@ -8,6 +8,8 @@ import {
eventSummarySchema,
FrigateEvent,
frigateEventsSchema,
PTZInfo,
ptzInfoSchema,
recordingSegmentsSchema,
RecordingSummary,
recordingSummarySchema,
@@ -151,3 +153,20 @@ export const getEventSummary = async (
true,
);
};
export const getPTZInfo = async (
hass: HomeAssistant,
clientID: string,
cameraName: string,
): Promise<PTZInfo> => {
return await homeAssistantWSRequest(
hass,
ptzInfoSchema,
{
type: 'frigate/ptz/info',
instance_id: clientID,
camera: cameraName,
},
true,
);
};
+7
View File
@@ -76,6 +76,13 @@ export const eventSummarySchema = z
.array();
export type EventSummary = z.infer<typeof eventSummarySchema>;
export const ptzInfoSchema = z.object({
name: z.string().optional(),
features: z.string().array().optional(),
presets: z.string().array().optional(),
});
export type PTZInfo = z.infer<typeof ptzInfoSchema>;
// ==============================
// Frigate concrete query results
// ==============================
+40 -30
View File
@@ -1,18 +1,22 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { CameraConfig } from '../../config/types';
import {
CameraConfig,
PTZAction,
PTZPhase
} 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 { Camera } from '../camera';
import { CameraManagerEngine } from '../engine';
import { CameraManagerReadOnlyConfigStore } from '../store';
import {
CameraConfigs,
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraManagerCameraCapabilities,
CameraManagerCameraMetadata,
CameraManagerMediaCapabilities,
DataQuery,
@@ -29,7 +33,7 @@ import {
RecordingQuery,
RecordingQueryResultsMap,
RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap,
RecordingSegmentsQueryResultsMap
} from '../types';
import { getCameraEntityFromConfig, getDefaultGo2RTCEndpoint } from '../utils.js';
@@ -42,12 +46,20 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
_hass: HomeAssistant,
_entityRegistryManager: EntityRegistryManager,
cameraConfig: CameraConfig,
): Promise<CameraConfig> {
return cameraConfig;
): Promise<Camera> {
return new Camera(cameraConfig, this, {
canFavoriteEvents: false,
canFavoriteRecordings: false,
canSeek: false,
supportsClips: false,
supportsRecordings: false,
supportsSnapshots: false,
supportsTimeline: false,
});
}
public generateDefaultEventQuery(
_cameras: CameraConfigs,
_store: CameraManagerReadOnlyConfigStore,
_cameraIDs: Set<string>,
_query: PartialEventQuery,
): EventQuery[] | null {
@@ -55,7 +67,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
}
public generateDefaultRecordingQuery(
_cameras: CameraConfigs,
_store: CameraManagerReadOnlyConfigStore,
_cameraIDs: Set<string>,
_query: PartialRecordingQuery,
): RecordingQuery[] | null {
@@ -63,7 +75,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
}
public generateDefaultRecordingSegmentsQuery(
_cameras: CameraConfigs,
_store: CameraManagerReadOnlyConfigStore,
_cameraIDs: Set<string>,
_query: PartialRecordingSegmentsQuery,
): RecordingSegmentsQuery[] | null {
@@ -72,7 +84,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
public async getEvents(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_store: CameraManagerReadOnlyConfigStore,
_query: EventQuery,
_engineOptions?: EngineOptions,
): Promise<EventQueryResultsMap | null> {
@@ -81,7 +93,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
public async getRecordings(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_store: CameraManagerReadOnlyConfigStore,
_query: RecordingQuery,
_engineOptions?: EngineOptions,
): Promise<RecordingQueryResultsMap | null> {
@@ -90,7 +102,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
public async getRecordingSegments(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_store: CameraManagerReadOnlyConfigStore,
_query: RecordingSegmentsQuery,
_engineOptions?: EngineOptions,
): Promise<RecordingSegmentsQueryResultsMap | null> {
@@ -99,7 +111,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
public generateMediaFromEvents(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_store: CameraManagerReadOnlyConfigStore,
_query: EventQuery,
_results: QueryReturnType<EventQuery>,
): ViewMedia[] | null {
@@ -108,7 +120,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
public generateMediaFromRecordings(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_store: CameraManagerReadOnlyConfigStore,
_query: RecordingQuery,
_results: QueryReturnType<RecordingQuery>,
): ViewMedia[] | null {
@@ -138,7 +150,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
public async getMediaSeekTime(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_store: CameraManagerReadOnlyConfigStore,
_media: ViewMedia,
_target: Date,
_engineOptions?: EngineOptions,
@@ -148,7 +160,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
public async getMediaMetadata(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_store: CameraManagerReadOnlyConfigStore,
_query: MediaMetadataQuery,
_engineOptions?: EngineOptions,
): Promise<MediaMetadataQueryResultsMap | null> {
@@ -173,20 +185,6 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
};
}
public getCameraCapabilities(
_cameraConfig: CameraConfig,
): CameraManagerCameraCapabilities | null {
return {
canFavoriteEvents: false,
canFavoriteRecordings: false,
canSeek: false,
supportsClips: false,
supportsRecordings: false,
supportsSnapshots: false,
supportsTimeline: false,
};
}
public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities | null {
return null;
}
@@ -202,4 +200,16 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
}
: null;
}
public async executePTZAction(
_hass: HomeAssistant,
_cameraConfig: CameraConfig,
_action: PTZAction,
_options?: {
phase?: PTZPhase;
preset?: string;
},
): Promise<void> {
// Pass.
}
}
+53 -78
View File
@@ -1,16 +1,14 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import add from 'date-fns/add';
import cloneDeep from 'lodash-es/cloneDeep';
import merge from 'lodash-es/merge.js';
import sum from 'lodash-es/sum';
import { CardCameraAPI } from '../card-controller/types.js';
import { CameraConfig, CamerasConfig } from '../config/types.js';
import { CameraConfig, CamerasConfig, PTZAction, PTZPhase } from '../config/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 { getCameraID } from '../utils/camera.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';
@@ -49,7 +47,7 @@ import {
RecordingSegmentsQuery,
RecordingSegmentsQueryResults,
RecordingSegmentsQueryResultsMap,
ResultsMap,
ResultsMap
} from './types.js';
import { sortMedia } from './utils.js';
@@ -102,31 +100,26 @@ export interface ExtendedMediaQueryResult<T extends MediaQuery> {
results: ViewMedia[];
}
interface InitializedCamera {
inputConfig: CameraConfig;
initializedConfig: CameraConfig;
engine: CameraManagerEngine;
}
export class CameraManager {
protected _api: CardCameraAPI;
protected _engineFactory: CameraManagerEngineFactory;
protected _store = new CameraManagerStore();
protected _store: CameraManagerStore;
constructor(api: CardCameraAPI) {
constructor(api: CardCameraAPI, store?: CameraManagerStore) {
this._api = api;
this._engineFactory = new CameraManagerEngineFactory(
this._api.getEntityRegistryManager(),
this._api.getResolvedMediaCache(),
);
this._store = store ?? new CameraManagerStore();
}
public async initializeCamerasFromConfig(): Promise<void> {
public async initializeCamerasFromConfig(): Promise<boolean> {
const config = this._api.getConfigManager().getConfig();
const hass = this._api.getHASSManager().getHASS();
if (!config || !hass) {
return;
return false;
}
this._store.reset();
@@ -143,7 +136,9 @@ export class CameraManager {
await this._initializeCameras(cameras);
} catch (e: unknown) {
this._api.getMessageManager().setErrorIfHigherPriority(e);
return false;
}
return true;
}
protected async _getEnginesForCameras(
@@ -172,7 +167,9 @@ export class CameraManager {
if (!engine || !engineType) {
throw new CameraInitializationError(
localize('error.no_camera_engine'),
cameraConfig,
// Camera initialization may modify the configuration. Keep the
// original config unchanged.
cloneDeep(cameraConfig),
);
}
engines.set(engineType, engine);
@@ -181,27 +178,6 @@ export class CameraManager {
return output;
}
protected async _initializeCamera(
hass: HomeAssistant,
engine: CameraManagerEngine,
entityRegistryManager: EntityRegistryManager,
inputCameraConfig: CameraConfig,
): Promise<InitializedCamera> {
const initializedConfig = await engine.initializeCamera(
hass,
entityRegistryManager,
// Camera initialization may modify the configuration. Keep the original
// for display in error messages to avoid user confusion.
cloneDeep(inputCameraConfig),
);
return {
inputConfig: inputCameraConfig,
initializedConfig: initializedConfig,
engine: engine,
};
}
protected async _initializeCameras(camerasConfig: CamerasConfig): Promise<void> {
const initializationStartTime = new Date();
const hass = this._api.getHASSManager().getHASS();
@@ -229,12 +205,11 @@ export class CameraManager {
const engineByConfig = await this._getEnginesForCameras(camerasConfig);
// Configuration is initialized in parallel.
const results = await allPromises(
const cameras = await allPromises(
engineByConfig.entries(),
async ([cameraConfig, engine]) =>
await this._initializeCamera(
await engine.initializeCamera(
hass,
engine,
this._api.getEntityRegistryManager(),
cameraConfig,
),
@@ -242,27 +217,26 @@ export class CameraManager {
// Do the additions based off the result-order, to ensure the map order is
// preserved.
results.forEach((result) => {
const id = getCameraID(result.initializedConfig);
cameras.forEach((camera) => {
const cameraID = getCameraID(camera.getConfig());
if (!id) {
if (!cameraID) {
throw new CameraInitializationError(
localize('error.no_camera_id'),
result.inputConfig,
camera.getConfig(),
);
}
if (this._store.hasCameraID(id)) {
if (this._store.hasCameraID(cameraID)) {
throw new CameraInitializationError(
localize('error.duplicate_camera_id'),
result.inputConfig,
camera.getConfig(),
);
}
// Always ensure the actual ID used in the card is in the configuration itself.
result.initializedConfig.id = id;
this._store.addCamera(id, result.initializedConfig, result.engine);
camera.setID(cameraID);
this._store.addCamera(camera);
});
if (!this._store.getVisibleCameraCount()) {
@@ -372,20 +346,16 @@ export class CameraManager {
for (const [engine, cameraIDs] of engines) {
let queries: DataQuery[] | null = null;
if (QueryClassifier.isEventQuery(partialQuery)) {
queries = engine.generateDefaultEventQuery(
this._store.getVisibleCameras(),
cameraIDs,
partialQuery,
);
queries = engine.generateDefaultEventQuery(this._store, cameraIDs, partialQuery);
} else if (QueryClassifier.isRecordingQuery(partialQuery)) {
queries = engine.generateDefaultRecordingQuery(
this._store.getVisibleCameras(),
this._store,
cameraIDs,
partialQuery,
);
} else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) {
queries = engine.generateDefaultRecordingSegmentsQuery(
this._store.getVisibleCameras(),
this._store,
cameraIDs,
partialQuery,
);
@@ -596,7 +566,7 @@ export class CameraManager {
return null;
}
return await engine.getMediaSeekTime(hass, this._store.getCameras(), media, target);
return await engine.getMediaSeekTime(hass, this._store, media, target);
}
protected async _handleQuery<QT extends DataQuery>(
@@ -624,28 +594,28 @@ export class CameraManager {
if (QueryClassifier.isEventQuery(query)) {
engineResult = (await engine.getEvents(
hass,
this._store.getCameras(),
this._store,
query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null;
} else if (QueryClassifier.isRecordingQuery(query)) {
engineResult = (await engine.getRecordings(
hass,
this._store.getCameras(),
this._store,
query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null;
} else if (QueryClassifier.isRecordingSegmentsQuery(query)) {
engineResult = (await engine.getRecordingSegments(
hass,
this._store.getCameras(),
this._store,
query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null;
} else if (QueryClassifier.isMediaMetadataQuery(query)) {
engineResult = (await engine.getMediaMetadata(
hass,
this._store.getCameras(),
this._store,
query,
engineOptions,
)) as Map<QT, QueryReturnType<QT>> | null;
@@ -710,22 +680,12 @@ export class CameraManager {
QueryClassifier.isEventQuery(query) &&
QueryResultClassifier.isEventQueryResult(result)
) {
media = engine.generateMediaFromEvents(
hass,
this._store.getCameras(),
query,
result,
);
media = engine.generateMediaFromEvents(hass, this._store, query, result);
} else if (
QueryClassifier.isRecordingQuery(query) &&
QueryResultClassifier.isRecordingQuery(result)
) {
media = engine.generateMediaFromRecordings(
hass,
this._store.getCameras(),
query,
result,
);
media = engine.generateMediaFromRecordings(hass, this._store, query, result);
}
if (media) {
mediaArray.push(...media);
@@ -761,12 +721,7 @@ export class CameraManager {
public getCameraCapabilities(
cameraID: string,
): CameraManagerCameraCapabilities | null {
const cameraConfig = this._store.getCameraConfig(cameraID);
const engine = this._store.getEngineForCameraID(cameraID);
if (!cameraConfig || !engine) {
return null;
}
return engine.getCameraCapabilities(cameraConfig);
return this._store.getCamera(cameraID)?.getCapabilities() ?? null;
}
public getAggregateCameraCapabilities(
@@ -787,6 +742,26 @@ export class CameraManager {
supportsRecordings: perCameraCapabilities.some((cap) => cap?.supportsRecordings),
supportsSnapshots: perCameraCapabilities.some((cap) => cap?.supportsSnapshots),
supportsTimeline: perCameraCapabilities.some((cap) => cap?.supportsTimeline),
supportsPTZ: perCameraCapabilities.some((cap) => !!cap?.ptz),
};
}
public async executePTZAction(
cameraID: string,
action: PTZAction,
options: {
phase?: PTZPhase;
preset?: string;
},
): Promise<void> {
const hass = this._api.getHASSManager().getHASS();
const engine = this._store.getEngineForCameraID(cameraID);
const cameraConfig = this._store.getCameraConfig(cameraID);
if (!engine || !cameraConfig || !hass) {
return;
}
return engine.executePTZAction(hass, cameraConfig, action, options);
}
}
@@ -8,25 +8,23 @@ import { CameraConfig } from '../../config/types';
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
import {
BrowseMediaStep,
BrowseMediaTarget,
BrowseMediaTarget
} from '../../utils/ha/browse-media/browse-media-manager';
import {
BROWSE_MEDIA_CACHE_SECONDS,
BrowseMedia,
MEDIA_CLASS_IMAGE,
BrowseMedia, BROWSE_MEDIA_CACHE_SECONDS, MEDIA_CLASS_IMAGE,
MEDIA_CLASS_VIDEO,
RichBrowseMedia,
RichBrowseMedia
} from '../../utils/ha/browse-media/types';
import { ViewMedia } from '../../view/media';
import {
BrowseMediaCameraManagerEngine,
getViewMediaFromBrowseMediaArray,
isMediaWithinDates,
isMediaWithinDates
} from '../browse-media/engine-browse-media';
import { BrowseMediaMetadata } from '../browse-media/types';
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
import { CameraManagerReadOnlyConfigStore } from '../store';
import {
CameraConfigs,
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
@@ -41,7 +39,7 @@ import {
MediaMetadataQueryResultsMap,
QueryResults,
QueryResultsType,
QueryReturnType,
QueryReturnType
} from '../types';
import motioneyeLogo from './assets/motioneye-logo.svg';
import { MotionEyeEventQueryResults } from './types';
@@ -126,7 +124,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
// Get media directories that match a given criteria.
protected async _getMatchingDirectories(
hass: HomeAssistant,
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
cameraID: string,
matchOptions?: {
start?: Date;
@@ -136,11 +134,11 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
} | null,
engineOptions?: EngineOptions,
): Promise<RichBrowseMedia<BrowseMediaMetadata>[] | null> {
const cameraEntityID = cameras.get(cameraID)?.camera_entity;
const cameraConfig = store.getCameraConfig(cameraID);
const cameraEntityID = cameraConfig?.camera_entity;
const entity = cameraEntityID ? this._cameraEntities.get(cameraEntityID) : null;
const configID = entity?.config_entry_id;
const deviceID = entity?.device_id;
const cameraConfig = cameras.get(cameraID);
if (!configID || !deviceID || !cameraConfig) {
return null;
@@ -206,7 +204,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
public async getEvents(
hass: HomeAssistant,
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
query: EventQuery,
engineOptions?: EngineOptions,
): Promise<EventQueryResultsMap | null> {
@@ -225,14 +223,14 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
return;
}
const cameraConfig = cameras.get(cameraID);
const cameraConfig = store.getCameraConfig(cameraID);
if (!cameraConfig) {
return;
}
const directories = await this._getMatchingDirectories(
hass,
cameras,
store,
cameraID,
perCameraQuery,
engineOptions,
@@ -309,7 +307,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
public generateMediaFromEvents(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_store: CameraManagerReadOnlyConfigStore,
_query: EventQuery,
results: QueryReturnType<EventQuery>,
): ViewMedia[] | null {
@@ -321,7 +319,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
public async getMediaMetadata(
hass: HomeAssistant,
cameras: CameraConfigs,
store: CameraManagerReadOnlyConfigStore,
query: MediaMetadataQuery,
engineOptions?: EngineOptions,
): Promise<MediaMetadataQueryResultsMap | null> {
@@ -340,7 +338,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
const getDaysForCamera = async (cameraID: string): Promise<void> => {
const directories = await this._getMatchingDirectories(
hass,
cameras,
store,
cameraID,
null,
engineOptions,
+82 -38
View File
@@ -1,7 +1,8 @@
import { CameraConfig } from '../config/types';
import { ViewMedia } from '../view/media';
import { Camera } from './camera';
import { CameraManagerEngine } from './engine';
import { CameraConfigs, Engine } from './types';
import { Engine } from './types';
type CameraManagerEngineCameraIDMap = Map<CameraManagerEngine, Set<string>>;
@@ -10,74 +11,82 @@ export interface CameraManagerReadOnlyConfigStore {
getCameraConfigForMedia(media: ViewMedia): CameraConfig | null;
hasCameraID(cameraID: string): boolean;
hasVisibleCameraID(cameraID: string): boolean;
getCamera(cameraID: string): Camera | null;
getCameraCount(): number;
getVisibleCameraCount(): number;
getCameras(): CameraConfigs;
getVisibleCameras(): CameraConfigs;
getCameraConfigs(cameraIDs?: Iterable<string>): IterableIterator<CameraConfig>;
getCameraConfigEntries(
cameraIDs?: Iterable<string>,
): IterableIterator<[string, CameraConfig]>;
getCameraIDs(): Set<string>;
getVisibleCameraIDs(): Set<string>;
getAllDependentCameras(cameraID: string): Set<string>;
}
export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
protected _allConfigs: Map<string, CameraConfig> = new Map();
protected _visibleConfigs: Map<string, CameraConfig> = new Map();
protected _enginesByCamera: Map<string, CameraManagerEngine> = new Map();
protected _cameras: Map<string, Camera> = new Map();
protected _enginesByType: Map<Engine, CameraManagerEngine> = new Map();
public addCamera(
cameraID: string,
cameraConfig: CameraConfig,
engine: CameraManagerEngine,
): void {
if (!cameraConfig.hide) {
this._visibleConfigs.set(cameraID, cameraConfig);
}
this._allConfigs.set(cameraID, cameraConfig);
this._enginesByCamera.set(cameraID, engine);
this._enginesByType.set(engine.getEngineType(), engine);
public addCamera(camera: Camera): void {
this._cameras.set(camera.getID(), camera);
this._enginesByType.set(camera.getEngine().getEngineType(), camera.getEngine());
}
public reset(): void {
this._allConfigs.clear();
this._visibleConfigs.clear();
this._enginesByCamera.clear();
this._cameras.clear();
this._enginesByType.clear();
}
public getCamera(cameraID: string): Camera | null {
return this._cameras.get(cameraID) ?? null;
}
public getCameraConfig(cameraID: string): CameraConfig | null {
return this._allConfigs.get(cameraID) ?? null;
return this._cameras.get(cameraID)?.getConfig() ?? null;
}
public hasCameraID(cameraID: string): boolean {
return this._allConfigs.has(cameraID);
}
public hasVisibleCameraID(cameraID: string): boolean {
return this._visibleConfigs.has(cameraID);
return this._cameras.has(cameraID);
}
public getCameraCount(): number {
return this._allConfigs.size;
return this._cameras.size;
}
public getVisibleCameraCount(): number {
return this._visibleConfigs.size;
return this.getVisibleCameraIDs().size;
}
public getCameras(): CameraConfigs {
return this._allConfigs;
public getCameras(): Map<string, Camera> {
return this._cameras;
}
public getVisibleCameras(): CameraConfigs {
return this._visibleConfigs;
public *getCameraConfigs(
cameraIDs?: Iterable<string>,
): IterableIterator<CameraConfig> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const [_cameraID, config] of this.getCameraConfigEntries(cameraIDs)) {
yield config;
}
}
public *getCameraConfigEntries(
cameraIDs?: Iterable<string>,
): IterableIterator<[string, CameraConfig]> {
for (const cameraID of cameraIDs ?? this._cameras.keys()) {
const config = this.getCameraConfig(cameraID);
if (config) {
yield [cameraID, config];
}
}
}
public getCameraIDs(): Set<string> {
return new Set(this._allConfigs.keys());
return new Set(this._cameras.keys());
}
public getVisibleCameraIDs(): Set<string> {
return new Set(this._visibleConfigs.keys());
return this._getMatchingCameraIDs((camera) => !camera.getConfig().hide);
}
public getCameraConfigForMedia(media: ViewMedia): CameraConfig | null {
@@ -89,7 +98,7 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
}
public getEngineForCameraID(cameraID: string): CameraManagerEngine | null {
return this._enginesByCamera.get(cameraID) ?? null;
return this._cameras.get(cameraID)?.getEngine() ?? null;
}
public getEnginesForCameraIDs(
@@ -114,7 +123,42 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
return this.getEngineForCameraID(media.getCameraID());
}
public getAllEngines(): CameraManagerEngine[] {
return [...this._enginesByType.values()];
/**
* Get all cameras that depend on a given camera.
* @param cameraManager The camera manager.
* @param cameraID ID of the target camera.
* @returns A set of dependent cameraIDs or null (since JS sets guarantee order,
* the first item in the set is guaranteed to be the cameraID itself).
*/
public getAllDependentCameras(cameraID: string): Set<string> {
const cameraIDs: Set<string> = new Set();
const getDependentCameras = (cameraID: string): void => {
const cameraConfig = this.getCameraConfig(cameraID);
if (cameraConfig) {
cameraIDs.add(cameraID);
const dependentCameras: Set<string> = new Set();
cameraConfig.dependencies.cameras.forEach((item) => dependentCameras.add(item));
if (cameraConfig.dependencies.all_cameras) {
this.getCameraIDs().forEach((cameraID) => dependentCameras.add(cameraID));
}
for (const eventCameraID of dependentCameras) {
if (!cameraIDs.has(eventCameraID)) {
getDependentCameras(eventCameraID);
}
}
}
};
getDependentCameras(cameraID);
return cameraIDs;
}
protected _getMatchingCameraIDs(func: (camera: Camera) => boolean): Set<string> {
const output = new Set<string>();
for (const [cameraID, camera] of this._cameras.entries()) {
if (func(camera)) {
output.add(cameraID);
}
}
return output;
}
}
+17 -5
View File
@@ -1,4 +1,4 @@
import { CameraConfig, FrigateCardView } from '../config/types';
import { FrigateCardView } from '../config/types';
import { ViewMedia } from '../view/media';
// ====
@@ -91,6 +91,14 @@ export interface MediaMetadata {
what?: Set<string>;
}
export type PTZMovementType = 'relative' | 'continuous';
export interface PTZCapabilities {
panTilt?: PTZMovementType[];
zoom?: PTZMovementType[];
presets?: string[];
}
interface BaseCapabilities {
canFavoriteEvents: boolean;
canFavoriteRecordings: boolean;
@@ -102,8 +110,14 @@ interface BaseCapabilities {
supportsTimeline: boolean;
}
export type CameraManagerCapabilities = BaseCapabilities;
export type CameraManagerCameraCapabilities = BaseCapabilities;
export interface CameraManagerCapabilities extends BaseCapabilities {
supportsPTZ: boolean;
}
export interface CameraManagerCameraCapabilities extends BaseCapabilities {
ptz?: PTZCapabilities;
}
export interface CameraManagerMediaCapabilities {
canFavorite: boolean;
canDownload: boolean;
@@ -132,8 +146,6 @@ export interface CameraEndpoints {
webrtcCard?: CameraEndpoint;
}
export type CameraConfigs = Map<string, CameraConfig>;
export interface EngineOptions {
useCache?: boolean;
}
+16
View File
@@ -216,6 +216,22 @@ export class ActionsManager {
.getViewManager()
.setViewWithNewDisplayMode(frigateCardAction.display_mode);
break;
case 'ptz':
const cameraID = this._api.getViewManager().getView()?.camera;
if (cameraID) {
this._api
.getCameraManager()
.executePTZAction(cameraID, frigateCardAction.ptz_action, {
phase: frigateCardAction.ptz_phase,
preset: frigateCardAction.ptz_preset,
});
}
break;
case 'show_ptz':
this._api
.getViewManager()
.setViewWithNewContext({ live: { ptzVisible: frigateCardAction.show_ptz } });
break;
default:
console.warn(`Frigate card received unknown card action: ${action}`);
}
+10 -7
View File
@@ -13,7 +13,7 @@ export enum InitializationAspect {
export class InitializationManager {
protected _api: CardInitializerAPI;
protected _initializer;
protected _initializer: Initializer;
constructor(api: CardInitializerAPI, initializer?: Initializer) {
this._api = api;
@@ -21,11 +21,15 @@ export class InitializationManager {
}
public isInitializedMandatory(): boolean {
return this._initializer.isInitializedMultiple([
InitializationAspect.LANGUAGES,
InitializationAspect.SIDE_LOAD_ELEMENTS,
InitializationAspect.CAMERAS,
]);
return (
this._initializer.isInitializedMultiple([
InitializationAspect.LANGUAGES,
InitializationAspect.SIDE_LOAD_ELEMENTS,
InitializationAspect.CAMERAS,
]) &&
// If there's no view, re-initialize (e.g. config changes).
this._api.getViewManager().hasView()
);
}
/**
@@ -80,7 +84,6 @@ export class InitializationManager {
}
}
this._api.getCardElementManager().update();
return true;
}
+4 -2
View File
@@ -25,10 +25,10 @@ export class MediaPlayerManager {
return this._mediaPlayers.length > 0;
}
public async initialize(): Promise<void> {
public async initialize(): Promise<boolean> {
const hass = this._api.getHASSManager().getHASS();
if (!hass) {
return;
return false;
}
const isValidMediaPlayer = (entityID: string): boolean => {
@@ -66,6 +66,8 @@ export class MediaPlayerManager {
const entity = mediaPlayerEntities?.get(entityID);
return !entity || !entity.hidden_by;
});
return true;
}
public async stop(mediaPlayer: string): Promise<void> {
+6 -2
View File
@@ -16,7 +16,7 @@ export class MicrophoneManager {
this._api = api;
}
public async connect(): Promise<void> {
public async connect(): Promise<boolean> {
try {
this._stream = await navigator.mediaDevices.getUserMedia({
audio: true,
@@ -24,15 +24,19 @@ export class MicrophoneManager {
});
} catch (e: unknown) {
errorToConsole(e as Error);
this._stream = null;
this._api.getCardElementManager().update();
return false;
}
this._setMute();
return true;
}
public async disconnect(): Promise<void> {
this._stream?.getTracks().forEach((track) => track.stop());
this._stream = undefined;
this._stream = undefined;
this._api.getCardElementManager().update();
}
+3 -4
View File
@@ -1,5 +1,5 @@
import { FrigateCardCustomAction, FrigateCardViewAction } from '../config/types';
import { createFrigateCardCustomAction } from '../utils/action.js';
import { createFrigateCardCameraAction, createFrigateCardSimpleAction } from '../utils/action.js';
import { CardQueryStringAPI } from './types';
import { ViewManagerSetViewParameters } from './view-manager';
@@ -139,8 +139,7 @@ export class QueryStringManager {
case 'camera_select':
case 'live_substream_select':
if (value) {
customAction = createFrigateCardCustomAction(action, {
camera: value,
customAction = createFrigateCardCameraAction(action, value, {
cardID: cardID,
});
}
@@ -160,7 +159,7 @@ export class QueryStringManager {
case 'snapshot':
case 'snapshots':
case 'timeline':
customAction = createFrigateCardCustomAction(action, {
customAction = createFrigateCardSimpleAction(action, {
cardID: cardID,
});
break;
+8 -2
View File
@@ -28,8 +28,14 @@ export class TriggersManager {
const now = new Date();
let triggerChanges = false;
const cameras = this._api.getCameraManager().getStore().getVisibleCameras();
for (const [cameraID, config] of cameras?.entries()) {
const visibleCameraIDs = this._api
.getCameraManager()
.getStore()
.getVisibleCameraIDs();
for (const [cameraID, config] of this._api
.getCameraManager()
.getStore()
.getCameraConfigEntries(visibleCameraIDs)) {
const triggerEntities = config.triggers.entities;
const diffs = getHassDifferences(hass, oldHass, triggerEntities, {
stateOnly: true,
+5 -2
View File
@@ -1,7 +1,6 @@
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';
@@ -28,6 +27,10 @@ export class ViewManager {
return this._view;
}
public hasView(): boolean {
return !!this.getView();
}
public setView(view: View): void {
this._setView(view);
}
@@ -255,7 +258,7 @@ export class ViewManager {
protected _createViewWithNextStream(baseView: View): View {
const dependencies = [
...getAllDependentCameras(this._api.getCameraManager(), baseView.camera),
...this._api.getCameraManager().getStore().getAllDependentCameras(baseView.camera),
];
if (dependencies.length <= 1) {
return baseView.clone();
+12
View File
@@ -163,6 +163,12 @@ class FrigateCard extends LitElement {
}
protected shouldUpdate(): boolean {
// Always allow messages to render, as a message may be generated during
// initialization.
if (this._controller.getMessageManager().hasMessage()) {
return true;
}
if (!this._controller.getInitializationManager().isInitializedMandatory()) {
this._controller.getInitializationManager().initializeMandatory();
return false;
@@ -295,6 +301,12 @@ class FrigateCard extends LitElement {
.view=${this._controller.getViewManager().getView()}
.cameraManager=${cameraManager}
.resolvedMediaCache=${this._controller.getResolvedMediaCache()}
.nonOverriddenConfig=${this._controller
.getConfigManager()
.getNonOverriddenConfig()}
.overriddenConfig=${this._controller.getConfigManager().getConfig()}
.cardWideConfig=${this._controller.getConfigManager().getCardWideConfig()}
.rawConfig=${this._controller.getConfigManager().getRawConfig()}
.configManager=${this._controller.getConfigManager()}
.conditionsManagerEpoch=${this._controller
.getConditionsManager()
+96 -81
View File
@@ -1,24 +1,29 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { StyleInfo } from 'lit/directives/style-map';
import { CameraManager } from '../camera-manager/manager';
import { MediaPlayerManager } from '../card-controller/media-player-manager';
import { MicrophoneManager } from '../card-controller/microphone-manager';
import {
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
FrigateCardConfig,
FrigateCardCustomAction,
MenuItem
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
MenuItem,
} from '../config/types';
import { FRIGATE_BUTTON_MENU_ICON } from '../const';
import { localize } from '../localize/localize.js';
import { MediaLoadedInfo } from '../types';
import {
MediaLoadedInfo,
} from '../types';
import { View } from '../view/view';
import { createFrigateCardCustomAction } from '../utils/action';
import { getAllDependentCameras } from '../utils/camera';
import { MediaPlayerManager } from '../card-controller/media-player-manager';
import { MicrophoneManager } from '../card-controller/microphone-manager';
createFrigateCardCameraAction,
createFrigateCardDisplayModeAction,
createFrigateCardMediaPlayerAction,
createFrigateCardShowPTZAction,
createFrigateCardSimpleAction,
} from '../utils/action';
import { getEntityIcon, getEntityTitle } from '../utils/ha';
import { hasUsablePTZ } from '../utils/ptz';
import { hasSubstream } from '../utils/substream';
import { View } from '../view/view';
export interface MenuButtonControllerOptions {
currentMediaLoadedInfo?: MediaLoadedInfo | null;
showCameraUIButton?: boolean;
@@ -55,15 +60,19 @@ export class MenuButtonController {
view: View,
options?: MenuButtonControllerOptions,
): MenuItem[] {
const visibleCameras = cameraManager.getStore().getVisibleCameras();
const visibleCameraIDs = cameraManager.getStore().getVisibleCameraIDs();
const selectedCameraID = view.camera;
const selectedCameraConfig = cameraManager
.getStore()
.getCameraConfig(selectedCameraID);
const allSelectedCameraIDs = getAllDependentCameras(cameraManager, selectedCameraID);
const allSelectedCameraIDs = cameraManager
.getStore()
.getAllDependentCameras(selectedCameraID);
const selectedMedia = view.queryResults?.getSelectedResult();
const cameraCapabilities =
const selectedCameraCapabilities =
cameraManager.getCameraCapabilities(selectedCameraID);
const aggregateCapabilities =
cameraManager.getAggregateCameraCapabilities(allSelectedCameraIDs);
const mediaCapabilities = selectedMedia
? cameraManager?.getMediaCapabilities(selectedMedia)
@@ -79,30 +88,31 @@ export class MenuButtonController {
title: localize('config.menu.buttons.frigate'),
tap_action:
config.menu?.style === 'hidden'
? (createFrigateCardCustomAction('menu_toggle') as FrigateCardCustomAction)
: (createFrigateCardCustomAction('default') as FrigateCardCustomAction),
hold_action: createFrigateCardCustomAction(
? (createFrigateCardSimpleAction('menu_toggle') as FrigateCardCustomAction)
: (createFrigateCardSimpleAction('default') as FrigateCardCustomAction),
hold_action: createFrigateCardSimpleAction(
'diagnostics',
) as FrigateCardCustomAction,
});
if (visibleCameras.size) {
const menuItems = Array.from(visibleCameras, ([cameraID, config]) => {
const action = createFrigateCardCustomAction('camera_select', {
camera: cameraID,
});
const metadata = cameraManager.getCameraMetadata(cameraID);
if (visibleCameraIDs.size) {
const menuItems = Array.from(
cameraManager.getStore().getCameraConfigEntries(visibleCameraIDs),
([cameraID, config]) => {
const action = createFrigateCardCameraAction('camera_select', cameraID);
const metadata = cameraManager.getCameraMetadata(cameraID);
return {
enabled: true,
icon: metadata?.icon,
entity: config.camera_entity,
state_color: true,
title: metadata?.title,
selected: selectedCameraID === cameraID,
...(action && { tap_action: action }),
};
});
return {
enabled: true,
icon: metadata?.icon,
entity: config.camera_entity,
state_color: true,
title: metadata?.title,
selected: selectedCameraID === cameraID,
...(action && { tap_action: action }),
};
},
);
buttons.push({
icon: 'mdi:video-switch',
@@ -127,15 +137,16 @@ export class MenuButtonController {
title: localize('config.menu.buttons.substreams'),
...config.menu.buttons.substreams,
type: 'custom:frigate-card-menu-icon',
tap_action: createFrigateCardCustomAction(
tap_action: createFrigateCardSimpleAction(
hasSubstream(view) ? 'live_substream_off' : 'live_substream_on',
) as FrigateCardCustomAction,
});
} else if (dependencies.length > 2) {
const menuItems = Array.from(dependencies, (cameraID) => {
const action = createFrigateCardCustomAction('live_substream_select', {
camera: cameraID,
});
const action = createFrigateCardCameraAction(
'live_substream_select',
cameraID,
);
const metadata = cameraManager.getCameraMetadata(cameraID) ?? undefined;
const cameraConfig = cameraManager.getStore().getCameraConfig(cameraID);
return {
@@ -169,48 +180,48 @@ export class MenuButtonController {
type: 'custom:frigate-card-menu-icon',
title: localize('config.view.views.live'),
style: view.is('live') ? this._getEmphasizedStyle() : {},
tap_action: createFrigateCardCustomAction('live') as FrigateCardCustomAction,
tap_action: createFrigateCardSimpleAction('live') as FrigateCardCustomAction,
});
if (cameraCapabilities?.supportsClips) {
if (aggregateCapabilities?.supportsClips) {
buttons.push({
icon: 'mdi:filmstrip',
...config.menu.buttons.clips,
type: 'custom:frigate-card-menu-icon',
title: localize('config.view.views.clips'),
style: view?.is('clips') ? this._getEmphasizedStyle() : {},
tap_action: createFrigateCardCustomAction('clips') as FrigateCardCustomAction,
hold_action: createFrigateCardCustomAction('clip') as FrigateCardCustomAction,
tap_action: createFrigateCardSimpleAction('clips') as FrigateCardCustomAction,
hold_action: createFrigateCardSimpleAction('clip') as FrigateCardCustomAction,
});
}
if (cameraCapabilities?.supportsSnapshots) {
if (aggregateCapabilities?.supportsSnapshots) {
buttons.push({
icon: 'mdi:camera',
...config.menu.buttons.snapshots,
type: 'custom:frigate-card-menu-icon',
title: localize('config.view.views.snapshots'),
style: view?.is('snapshots') ? this._getEmphasizedStyle() : {},
tap_action: createFrigateCardCustomAction(
tap_action: createFrigateCardSimpleAction(
'snapshots',
) as FrigateCardCustomAction,
hold_action: createFrigateCardCustomAction(
hold_action: createFrigateCardSimpleAction(
'snapshot',
) as FrigateCardCustomAction,
});
}
if (cameraCapabilities?.supportsRecordings) {
if (aggregateCapabilities?.supportsRecordings) {
buttons.push({
icon: 'mdi:album',
...config.menu.buttons.recordings,
type: 'custom:frigate-card-menu-icon',
title: localize('config.view.views.recordings'),
style: view.is('recordings') ? this._getEmphasizedStyle() : {},
tap_action: createFrigateCardCustomAction(
tap_action: createFrigateCardSimpleAction(
'recordings',
) as FrigateCardCustomAction,
hold_action: createFrigateCardCustomAction(
hold_action: createFrigateCardSimpleAction(
'recording',
) as FrigateCardCustomAction,
});
@@ -222,19 +233,19 @@ export class MenuButtonController {
type: 'custom:frigate-card-menu-icon',
title: localize('config.view.views.image'),
style: view?.is('image') ? this._getEmphasizedStyle() : {},
tap_action: createFrigateCardCustomAction('image') as FrigateCardCustomAction,
tap_action: createFrigateCardSimpleAction('image') as FrigateCardCustomAction,
});
// Don't show the timeline button unless there's at least one non-birdseye
// camera with a Frigate camera name.
if (cameraCapabilities?.supportsTimeline) {
if (aggregateCapabilities?.supportsTimeline) {
buttons.push({
icon: 'mdi:chart-gantt',
...config.menu.buttons.timeline,
type: 'custom:frigate-card-menu-icon',
title: localize('config.view.views.timeline'),
style: view.is('timeline') ? this._getEmphasizedStyle() : {},
tap_action: createFrigateCardCustomAction('timeline') as FrigateCardCustomAction,
tap_action: createFrigateCardSimpleAction('timeline') as FrigateCardCustomAction,
});
}
@@ -244,7 +255,7 @@ export class MenuButtonController {
...config.menu.buttons.download,
type: 'custom:frigate-card-menu-icon',
title: localize('config.menu.buttons.download'),
tap_action: createFrigateCardCustomAction('download') as FrigateCardCustomAction,
tap_action: createFrigateCardSimpleAction('download') as FrigateCardCustomAction,
});
}
@@ -254,7 +265,7 @@ export class MenuButtonController {
...config.menu.buttons.camera_ui,
type: 'custom:frigate-card-menu-icon',
title: localize('config.menu.buttons.camera_ui'),
tap_action: createFrigateCardCustomAction(
tap_action: createFrigateCardSimpleAction(
'camera_ui',
) as FrigateCardCustomAction,
});
@@ -279,16 +290,16 @@ export class MenuButtonController {
style: forbidden || muted ? {} : this._getEmphasizedStyle(true),
...(!forbidden &&
buttonType === 'momentary' && {
start_tap_action: createFrigateCardCustomAction(
start_tap_action: createFrigateCardSimpleAction(
'microphone_unmute',
) as FrigateCardCustomAction,
end_tap_action: createFrigateCardCustomAction(
end_tap_action: createFrigateCardSimpleAction(
'microphone_mute',
) as FrigateCardCustomAction,
}),
...(!forbidden &&
buttonType === 'toggle' && {
tap_action: createFrigateCardCustomAction(
tap_action: createFrigateCardSimpleAction(
options.microphoneManager.isMuted()
? 'microphone_unmute'
: 'microphone_mute',
@@ -303,7 +314,7 @@ export class MenuButtonController {
...config.menu.buttons.fullscreen,
type: 'custom:frigate-card-menu-icon',
title: localize('config.menu.buttons.fullscreen'),
tap_action: createFrigateCardCustomAction(
tap_action: createFrigateCardSimpleAction(
'fullscreen',
) as FrigateCardCustomAction,
style: options?.inFullscreenMode ? this._getEmphasizedStyle() : {},
@@ -315,7 +326,7 @@ export class MenuButtonController {
...config.menu.buttons.expand,
type: 'custom:frigate-card-menu-icon',
title: localize('config.menu.buttons.expand'),
tap_action: createFrigateCardCustomAction('expand') as FrigateCardCustomAction,
tap_action: createFrigateCardSimpleAction('expand') as FrigateCardCustomAction,
style: options?.inExpandedMode ? this._getEmphasizedStyle() : {},
});
@@ -328,14 +339,8 @@ export class MenuButtonController {
.map((playerEntityID) => {
const title = getEntityTitle(hass, playerEntityID) || playerEntityID;
const state = hass.states[playerEntityID];
const playAction = createFrigateCardCustomAction('media_player', {
media_player: playerEntityID,
media_player_action: 'play',
});
const stopAction = createFrigateCardCustomAction('media_player', {
media_player: playerEntityID,
media_player_action: 'stop',
});
const playAction = createFrigateCardMediaPlayerAction(playerEntityID, 'play');
const stopAction = createFrigateCardMediaPlayerAction(playerEntityID, 'stop');
const disabled = !state || state.state === 'unavailable';
return {
@@ -368,7 +373,7 @@ export class MenuButtonController {
...config.menu.buttons.play,
type: 'custom:frigate-card-menu-icon',
title: localize('config.menu.buttons.play'),
tap_action: createFrigateCardCustomAction(
tap_action: createFrigateCardSimpleAction(
paused ? 'play' : 'pause',
) as FrigateCardCustomAction,
});
@@ -381,7 +386,7 @@ export class MenuButtonController {
...config.menu.buttons.mute,
type: 'custom:frigate-card-menu-icon',
title: localize('config.menu.buttons.mute'),
tap_action: createFrigateCardCustomAction(
tap_action: createFrigateCardSimpleAction(
muted ? 'unmute' : 'mute',
) as FrigateCardCustomAction,
});
@@ -394,29 +399,39 @@ export class MenuButtonController {
...config.menu.buttons.screenshot,
type: 'custom:frigate-card-menu-icon',
title: localize('config.menu.buttons.screenshot'),
tap_action: createFrigateCardCustomAction(
tap_action: createFrigateCardSimpleAction(
'screenshot',
) as FrigateCardCustomAction,
});
}
if (view.supportsMultipleDisplayModes() && visibleCameras.size > 1) {
if (view.supportsMultipleDisplayModes() && visibleCameraIDs.size > 1) {
const isGrid = view.isGrid();
const action = createFrigateCardCustomAction('display_mode_select', {
display_mode: isGrid ? 'single' : 'grid',
buttons.push({
icon: isGrid ? 'mdi:grid-off' : 'mdi:grid',
...config.menu.buttons.display_mode,
style: isGrid ? this._getEmphasizedStyle() : {},
type: 'custom:frigate-card-menu-icon',
title: isGrid
? localize('display_modes.single')
: localize('display_modes.grid'),
tap_action: createFrigateCardDisplayModeAction(isGrid ? 'single' : 'grid'),
});
}
if (hasUsablePTZ(selectedCameraCapabilities, config.live.controls.ptz)) {
const isOn =
view.context?.live?.ptzVisible === false
? false
: config.live.controls.ptz.mode === 'on';
buttons.push({
icon: 'mdi:pan',
...config.menu.buttons.ptz,
style: isOn ? this._getEmphasizedStyle() : {},
type: 'custom:frigate-card-menu-icon',
title: localize('config.menu.buttons.ptz'),
tap_action: createFrigateCardShowPTZAction(!isOn),
});
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (action) {
buttons.push({
icon: isGrid ? 'mdi:grid-off' : 'mdi:grid',
...config.menu.buttons.display_mode,
type: 'custom:frigate-card-menu-icon',
title: isGrid
? localize('display_modes.single')
: localize('display_modes.grid'),
tap_action: action,
});
}
}
const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({
+170
View File
@@ -0,0 +1,170 @@
import { HASSDomEvent, HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { CameraManager } from '../camera-manager/manager';
import {
Actions,
ActionsConfig,
FrigateCardPTZAction,
FrigateCardPTZActions,
FrigateCardPTZConfig,
PTZAction,
PTZControlAction,
PTZ_CONTROL_ACTIONS,
} from '../config/types';
import {
frigateCardHandleActionConfig,
getActionConfigGivenAction,
} from '../utils/action';
export class PTZController {
private _host: HTMLElement;
private _config: FrigateCardPTZConfig | null = null;
private _hass: HomeAssistant | null = null;
private _cameraManager: CameraManager | null = null;
private _cameraID: string | null = null;
private _actions: FrigateCardPTZActions | null = null;
private _forceVisibility?: boolean;
constructor(host: HTMLElement) {
this._host = host;
}
public setConfig(config?: FrigateCardPTZConfig) {
this._config = config ?? null;
this._host.setAttribute('data-orientation', config?.orientation ?? 'horizontal');
this._host.setAttribute('data-position', config?.position ?? 'bottom-right');
this._host.setAttribute(
'style',
Object.entries(config?.style ?? {})
.map(([k, v]) => `${k}:${v}`)
.join(';'),
);
}
public getConfig(): FrigateCardPTZConfig | null {
return this._config;
}
public setHASS(hass?: HomeAssistant) {
this._hass = hass ?? null;
}
public setCamera(cameraManager?: CameraManager, cameraID?: string) {
this._cameraManager = cameraManager ?? null;
this._cameraID = cameraID ?? null;
this._calculateActions();
}
public setForceVisibility(forceVisibility?: boolean): void {
this._forceVisibility = forceVisibility;
}
public handleAction(
ev: HASSDomEvent<{ action: string }>,
config?: ActionsConfig | null,
): void {
// Nothing else has the configuration for this action, so don't let it
// propagate further.
ev.stopPropagation();
const interaction: string = ev.detail.action;
const action = getActionConfigGivenAction(interaction, config);
if (config && action && this._hass) {
frigateCardHandleActionConfig(this._host, this._hass, config, interaction, action);
}
}
public getPTZActions(actionName: PTZControlAction): Actions | null {
const propertyName = 'actions_' + actionName;
return this._config?.[propertyName] ?? this._actions?.[propertyName] ?? null;
}
private _hasAnyAction(): boolean {
for (const actionName of PTZ_CONTROL_ACTIONS) {
if ('actions_' + actionName in (this._actions ?? {})) {
return true;
}
}
return false;
}
public shouldDisplay(): boolean {
return this._forceVisibility === false
? false
: this._config?.mode === 'on' && this._hasAnyAction();
}
private _calculateActions(): void {
const getDefaultAction = (
ptzAction: PTZAction,
options?: {
phase?: 'start' | 'stop';
preset?: string;
},
): FrigateCardPTZAction => ({
action: 'fire-dom-event',
frigate_card_action: 'ptz',
ptz_action: ptzAction,
...(options?.phase && { ptz_phase: options.phase }),
...(options?.preset && { ptz_preset: options.preset }),
});
const getDefaultActions = (
ptzAction: PTZAction,
continuous: boolean,
preset?: string,
): Actions =>
continuous
? {
start_tap_action: getDefaultAction(ptzAction, {
phase: 'start',
preset: preset,
}),
end_tap_action: getDefaultAction(ptzAction, {
phase: 'stop',
preset: preset,
}),
}
: {
tap_action: getDefaultAction(ptzAction, { preset: preset }),
};
if (!this._cameraManager || !this._cameraID) {
return;
}
const ptzCapabilities = this._cameraManager.getCameraCapabilities(
this._cameraID,
)?.ptz;
const defaultActions: FrigateCardPTZActions = {};
const panTilt = ptzCapabilities?.panTilt;
const zoom = ptzCapabilities?.zoom;
const presets = ptzCapabilities?.presets;
if (panTilt?.length) {
const continuous = panTilt.includes('continuous');
defaultActions.actions_up = getDefaultActions('up', continuous);
defaultActions.actions_down = getDefaultActions('down', continuous);
defaultActions.actions_left = getDefaultActions('left', continuous);
defaultActions.actions_right = getDefaultActions('right', continuous);
}
if (zoom?.length) {
const continuous = zoom.includes('continuous');
defaultActions.actions_zoom_in = getDefaultActions('zoom_in', continuous);
defaultActions.actions_zoom_out = getDefaultActions('zoom_out', continuous);
}
if (presets?.length) {
defaultActions.actions_home = getDefaultActions('preset', false, presets[0]);
}
this._actions = {
...defaultActions,
...this._config,
};
}
}
+8 -1
View File
@@ -69,7 +69,14 @@ export class FrigateCardCarousel extends LitElement {
this.setAttribute('direction', this.direction);
}
const destroyProperties = ['direction', 'dragFree', 'transitionEffect'] as const;
const destroyProperties = [
'direction',
'dragEnabled',
'dragFree',
'loop',
'plugins',
'transitionEffect',
] as const;
if (destroyProperties.some((prop) => changedProps.has(prop))) {
this._carousel?.destroy();
this._carousel = null;
+8 -128
View File
@@ -1,19 +1,18 @@
import { HASSDomEvent, HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
html,
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { actionHandler } from '../action-handler-directive.js';
import {
Actions,
ActionsConfig,
FrigateCardPTZConfig,
ConditionsManagerEpoch,
evaluateConditionViaEvent,
} from '../card-controller/conditions-manager.js';
import {
FrigateConditional,
MenuIcon,
MenuItem,
@@ -23,19 +22,9 @@ import {
PictureElements,
} from '../config/types.js';
import { localize } from '../localize/localize.js';
import ptzStyle from '../scss/elements-ptz.scss';
import elementsStyle from '../scss/elements.scss';
import { FrigateCardError } from '../types.js';
import {
frigateCardHandleActionConfig,
frigateCardHasAction,
getActionConfigGivenAction,
} from '../utils/action.js';
import { dispatchFrigateCardEvent } from '../utils/basic.js';
import {
ConditionsManagerEpoch,
evaluateConditionViaEvent,
} from '../card-controller/conditions-manager.js';
import { dispatchFrigateCardEvent, errorToConsole } from '../utils/basic.js';
import { dispatchFrigateCardErrorEvent } from './message.js';
/* A note on picture element rendering:
@@ -122,7 +111,7 @@ export class FrigateCardElementsCore extends LitElement {
try {
element.setConfig(config);
} catch (e) {
console.error(e);
errorToConsole(e as Error, console.error);
throw new FrigateCardError(localize('error.invalid_elements_config'));
}
return element;
@@ -342,114 +331,6 @@ export class FrigateCardElementsMenuSubmenu extends FrigateCardElementsBaseMenuI
@customElement('frigate-card-menu-submenu-select')
export class FrigateCardElementsMenuSubmenuSelect extends FrigateCardElementsBaseMenuIcon<MenuSubmenuSelect> {}
@customElement('frigate-card-ptz')
export class FrigateCardPTZ extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@state()
protected _config: FrigateCardPTZConfig | null = null;
/**
* Set the card config.
* @param config The configuration.
*/
public setConfig(config: FrigateCardPTZConfig): void {
this._config = config;
}
/**
* Called before each update.
*/
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('_config')) {
this.setAttribute('data-orientation', this._config?.orientation ?? 'vertical');
}
}
/**
* Handle a PTZ action.
* @param ev The actionHandler event.
* @param config The action configuration.
*/
protected _actionHandler(
ev: HASSDomEvent<{ action: string }>,
config?: ActionsConfig,
): void {
// Nothing else has the configuration for this action, so don't let it
// propagate further.
ev.stopPropagation();
const interaction: string = ev.detail.action;
const action = getActionConfigGivenAction(interaction, config);
if (config && action && this.hass) {
frigateCardHandleActionConfig(this, this.hass, config, interaction, action);
}
}
/**
* Render the elements.
* @returns A rendered template or void.
*/
protected render(): TemplateResult | void {
if (!this._config) {
return;
}
const renderIcon = (
name: string,
icon: string,
actions?: Actions,
): TemplateResult => {
const hasHold = frigateCardHasAction(actions?.hold_action);
const hasDoubleClick = frigateCardHasAction(actions?.double_tap_action);
const classes = {
[name]: true,
disabled: !actions,
};
return html`<ha-icon
class=${classMap(classes)}
icon=${icon}
.actionHandler=${actionHandler({
hasHold: hasHold,
hasDoubleClick: hasDoubleClick,
})}
.title=${localize(`elements.ptz.${name}`)}
@action=${(ev) => this._actionHandler(ev, actions)}
></ha-icon>`;
};
return html` <div class="ptz">
<div class="ptz-move">
${renderIcon('right', 'mdi:arrow-right', this._config.actions_right)}
${renderIcon('left', 'mdi:arrow-left', this._config.actions_left)}
${renderIcon('up', 'mdi:arrow-up', this._config.actions_up)}
${renderIcon('down', 'mdi:arrow-down', this._config.actions_down)}
</div>
${this._config.actions_zoom_in || this._config.actions_zoom_out
? html` <div class="ptz-zoom">
${renderIcon('zoom_in', 'mdi:plus', this._config.actions_zoom_in)}
${renderIcon('zoom_out', 'mdi:minus', this._config.actions_zoom_out)}
</div>`
: html``}
${this._config.actions_home
? html`
<div class="ptz-home">
${renderIcon('home', 'mdi:home', this._config.actions_home)}
</div>
`
: html``}
</div>`;
}
/**
* Return compiled CSS styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(ptzStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-conditional': FrigateCardElementsConditional;
@@ -459,6 +340,5 @@ declare global {
'frigate-card-menu-state-icon': FrigateCardElementsMenuStateIcon;
'frigate-card-menu-icon': FrigateCardElementsMenuIcon;
'frigate-card-elements-core': FrigateCardElementsCore;
'frigate-card-ptz': FrigateCardPTZ;
}
}
+47 -39
View File
@@ -13,7 +13,7 @@ import { ifDefined } from 'lit/directives/if-defined.js';
import { keyed } from 'lit/directives/keyed.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { CameraConfigs, CameraEndpoints } from '../../camera-manager/types.js';
import { CameraEndpoints } from '../../camera-manager/types.js';
import {
ConditionsManagerEpoch,
getOverriddenConfig,
@@ -45,6 +45,7 @@ import { AutoLazyLoad } from '../../utils/embla/plugins/auto-lazy-load/auto-lazy
import { AutoMediaActions } from '../../utils/embla/plugins/auto-media-actions/auto-media-actions.js';
import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
import AutoSize from '../../utils/embla/plugins/auto-size/auto-size.js';
import { getStateObjOrDispatchError } from '../../utils/get-state-obj.js';
import {
dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaUnloadedEvent,
@@ -55,18 +56,20 @@ import { dispatchViewContextChangeEvent, View } from '../../view/view.js';
import { EmblaCarouselPlugins } from '../carousel.js';
import { renderMessage } from '../message.js';
import '../next-prev-control.js';
import '../ptz.js';
import { FrigateCardPTZ } from '../ptz.js';
import '../surround.js';
import '../title-control.js';
import {
FrigateCardTitleControl,
getDefaultTitleConfigForView,
} from '../title-control.js';
import { getStateObjOrDispatchError } from '../../utils/get-state-obj.js';
interface LiveViewContext {
// A cameraID override (used for dependencies/substreams to force a different
// camera to be live rather than the camera selected in the view).
overrides?: Map<string, string>;
ptzVisible?: boolean;
}
declare module 'view' {
@@ -387,6 +390,10 @@ export class FrigateCardLiveCarousel extends LitElement {
// Index between camera name and slide number.
protected _cameraToSlide: Record<string, number> = {};
protected _refTitleControl: Ref<FrigateCardTitleControl> = createRef();
protected _refPTZControl: Ref<FrigateCardPTZ> = createRef();
@state()
protected _mediaHasLoaded = false;
protected _getTransitionEffect(): TransitionEffect {
return (
@@ -449,25 +456,20 @@ export class FrigateCardLiveCarousel extends LitElement {
}
protected _getSlides(): [TemplateResult[], Record<string, number>] {
let cameras: CameraConfigs | null = null;
if (this.viewFilterCameraID) {
const config = this.cameraManager
?.getStore()
.getCameraConfig(this.viewFilterCameraID);
if (config) {
cameras = new Map([[this.viewFilterCameraID, config]]);
}
} else {
cameras = this.cameraManager?.getStore().getVisibleCameras() ?? null;
}
if (!cameras) {
if (!this.cameraManager) {
return [[], {}];
}
const cameraIDs = this.viewFilterCameraID
? new Set([this.viewFilterCameraID])
: this.cameraManager.getStore().getVisibleCameraIDs();
const slides: TemplateResult[] = [];
const cameraToSlide: Record<string, number> = {};
for (const [cameraID, cameraConfig] of cameras) {
for (const [cameraID, cameraConfig] of this.cameraManager
.getStore()
.getCameraConfigEntries(cameraIDs)) {
const liveCameraID =
this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID;
const liveCameraConfig =
@@ -487,9 +489,9 @@ export class FrigateCardLiveCarousel extends LitElement {
}
protected _setViewHandler(ev: CustomEvent<CarouselSelected>): void {
const cameras = this.cameraManager?.getStore().getVisibleCameras();
if (cameras && ev.detail.index !== this._getSelectedCameraIndex()) {
this._setViewCameraID(Array.from(cameras.keys())[ev.detail.index]);
const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
if (cameraIDs && ev.detail.index !== this._getSelectedCameraIndex()) {
this._setViewCameraID([...cameraIDs][ev.detail.index]);
}
}
@@ -575,22 +577,23 @@ export class FrigateCardLiveCarousel extends LitElement {
}
protected _getCameraIDsOfNeighbors(): [string | null, string | null] {
const cameras = this.cameraManager?.getStore().getVisibleCameras();
if (this.viewFilterCameraID || !cameras || !this.view || !this.hass) {
const cameraIDs = this.cameraManager
? [...this.cameraManager.getStore().getVisibleCameraIDs()]
: [];
if (this.viewFilterCameraID || cameraIDs.length <= 1 || !this.view || !this.hass) {
return [null, null];
}
const cameraID = this.viewFilterCameraID ?? this.view.camera;
const keys = Array.from(cameras.keys());
const currentIndex = keys.indexOf(cameraID);
const currentIndex = cameraIDs.indexOf(cameraID);
if (currentIndex < 0 || cameras.size <= 1) {
if (currentIndex < 0) {
return [null, null];
}
return [
keys[currentIndex > 0 ? currentIndex - 1 : cameras.size - 1],
keys[currentIndex + 1 < cameras.size ? currentIndex + 1 : 0],
cameraIDs[currentIndex > 0 ? currentIndex - 1 : cameraIDs.length - 1],
cameraIDs[currentIndex + 1 < cameraIDs.length ? currentIndex + 1 : 0],
];
}
@@ -608,18 +611,19 @@ export class FrigateCardLiveCarousel extends LitElement {
const hasMultipleCameras = slides.length > 1;
const [prevID, nextID] = this._getCameraIDsOfNeighbors();
const overrideCameraID = (cameraID: string): string => {
const getOverrideCameraID = (cameraID: string): string => {
return this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID;
};
const cameraMetadataPrevious = prevID
? this.cameraManager.getCameraMetadata(overrideCameraID(prevID))
? this.cameraManager.getCameraMetadata(getOverrideCameraID(prevID))
: null;
const cameraID = this.viewFilterCameraID ?? this.view.camera;
const cameraMetadataCurrent = this.cameraManager.getCameraMetadata(
overrideCameraID(this.viewFilterCameraID ?? this.view.camera),
getOverrideCameraID(cameraID),
);
const cameraMetadataNext = nextID
? this.cameraManager.getCameraMetadata(overrideCameraID(nextID))
? this.cameraManager.getCameraMetadata(getOverrideCameraID(nextID))
: null;
const titleConfig = getDefaultTitleConfigForView(
@@ -656,6 +660,10 @@ export class FrigateCardLiveCarousel extends LitElement {
if (this._refTitleControl.value) {
this._refTitleControl.value.show();
}
this._mediaHasLoaded = true;
}}
@frigate-card:media:unloaded=${() => {
this._mediaHasLoaded = false;
}}
>
<frigate-card-next-previous-control
@@ -688,6 +696,14 @@ export class FrigateCardLiveCarousel extends LitElement {
>
</frigate-card-next-previous-control>
</frigate-card-carousel>
<frigate-card-ptz
.hass=${this.hass}
.config=${this.overriddenLiveConfig.controls.ptz}
.cameraManager=${this.cameraManager}
.cameraID=${cameraID}
.forceVisibility=${this._mediaHasLoaded && this.view.context?.live?.ptzVisible}
>
</frigate-card-ptz>
${cameraMetadataCurrent && titleConfig
? html`<frigate-card-title-control
${ref(this._refTitleControl)}
@@ -846,9 +862,6 @@ export class FrigateCardLiveProvider
);
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
this._isVideoMediaLoaded = false;
}
@@ -860,9 +873,6 @@ export class FrigateCardLiveProvider
this._isVideoMediaLoaded = true;
}
/**
* Called before each update.
*/
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('load')) {
if (!this.load) {
@@ -915,10 +925,6 @@ export class FrigateCardLiveProvider
: template;
}
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
if (!this.load || !this.hass || !this.liveConfig || !this.cameraConfig) {
return;
@@ -941,6 +947,8 @@ export class FrigateCardLiveProvider
return;
}
if (stateObj.state === 'unavailable') {
dispatchMediaUnloadedEvent(this);
// An unavailable camera gets a message rendered in place vs dispatched,
// as this may be a common occurrence (e.g. Frigate cameras that stop
// receiving frames). Otherwise a single temporarily unavailable camera
+8 -8
View File
@@ -167,8 +167,8 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_ev: CustomEvent<{ value: unknown }>,
): Promise<void> {
const cameras = this.cameraManager?.getStore().getVisibleCameras();
if (!this.hass || !cameras || !this.cameraManager || !this.view) {
const visibleCameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
if (!this.hass || !visibleCameraIDs || !this.cameraManager || !this.view) {
return;
}
@@ -182,7 +182,7 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
};
const cameraIDs =
getArrayValueAsSet(this._refCamera.value?.value) ?? new Set(cameras.keys());
getArrayValueAsSet(this._refCamera.value?.value) ?? visibleCameraIDs;
const mediaType = this._refMediaType.value?.value as
| MediaFilterMediaType
| undefined;
@@ -268,9 +268,9 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('cameraManager')) {
const cameras = this.cameraManager?.getStore().getVisibleCameras();
const cameras = this.cameraManager?.getStore().getVisibleCameraIDs();
if (cameras) {
this._cameraOptions = Array.from(cameras.keys()).map((cameraID) => ({
this._cameraOptions = [...cameras].map((cameraID) => ({
value: cameraID,
label: this.hass
? this.cameraManager?.getCameraMetadata(cameraID)?.title ?? ''
@@ -319,8 +319,8 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
protected _getDefaultsFromView(): MediaFilterCoreDefaults | null {
const queries = this.view?.query?.getQueries();
const cameras = this.cameraManager?.getStore().getVisibleCameras();
if (!this.view || !queries || !cameras) {
const visibleCameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
if (!this.view || !queries || !visibleCameraIDs) {
return null;
}
@@ -337,7 +337,7 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
);
// Special note: If all visible cameras are selected, this is the same as no
// selector at all.
if (cameraIDSets.length === 1 && !isEqual(queries[0].cameraIDs, cameras)) {
if (cameraIDSets.length === 1 && !isEqual(queries[0].cameraIDs, visibleCameraIDs)) {
cameraIDs = [...queries[0].cameraIDs];
}
+1 -1
View File
@@ -97,7 +97,7 @@ export class FrigateCardMenu extends LitElement {
* @param action The action to check.
* @returns `true` if the action toggles the menu, `false` otherwise.
*/
protected _isMenuToggleAction(action: ActionType | undefined): boolean {
protected _isMenuToggleAction(action: ActionType | null): boolean {
// Determine if this action is a Frigate card action, if so handle it
// internally.
if (!action) {
+131
View File
@@ -0,0 +1,131 @@
import { HASSDomEvent, HomeAssistant } from '@dermotduffy/custom-card-helpers';
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { actionHandler } from '../action-handler-directive.js';
import { CameraManager } from '../camera-manager/manager.js';
import { PTZController } from '../components-lib/ptz-controller.js';
import { Actions, FrigateCardPTZConfig } from '../config/types.js';
import { localize } from '../localize/localize.js';
import ptzStyle from '../scss/ptz.scss';
import { frigateCardHasAction } from '../utils/action.js';
@customElement('frigate-card-ptz')
export class FrigateCardPTZ extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public config?: FrigateCardPTZConfig;
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public cameraID?: string;
@property({ attribute: false })
public forceVisibility?: boolean;
protected _controller = new PTZController(this);
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('config')) {
this._controller.setConfig(this.config);
}
if (changedProps.has('hass')) {
this._controller.setHASS(this.hass);
}
if (changedProps.has('cameraManager') || changedProps.has('cameraID')) {
this._controller.setCamera(this.cameraManager, this.cameraID);
}
if (changedProps.has('forceVisibility')) {
this._controller.setForceVisibility(this.forceVisibility);
}
}
protected render(): TemplateResult | void {
if (!this._controller.shouldDisplay()) {
return;
}
const renderIcon = (
name: string,
icon: string,
actions: Actions | null,
): TemplateResult => {
const classes = {
[name]: true,
disabled: !actions,
};
return html`<ha-icon
class=${classMap(classes)}
icon=${icon}
.actionHandler=${actionHandler({
hasHold: frigateCardHasAction(actions?.hold_action),
hasDoubleClick: frigateCardHasAction(actions?.double_tap_action),
})}
.title=${localize(`elements.ptz.${name}`)}
@action=${(ev: HASSDomEvent<{ action: string }>) =>
this._controller.handleAction(ev, actions)}
></ha-icon>`;
};
const config = this._controller.getConfig();
const actionsZoomIn = this._controller.getPTZActions('zoom_in');
const actionsZoomOut = this._controller.getPTZActions('zoom_out');
const actionsHome = this._controller.getPTZActions('home');
return html` <div class="ptz">
${!config?.hide_pan_tilt
? html`<div class="ptz-move">
${renderIcon(
'right',
'mdi:arrow-right',
this._controller.getPTZActions('right'),
)}
${renderIcon(
'left',
'mdi:arrow-left',
this._controller.getPTZActions('left'),
)}
${renderIcon('up', 'mdi:arrow-up', this._controller.getPTZActions('up'))}
${renderIcon(
'down',
'mdi:arrow-down',
this._controller.getPTZActions('down'),
)}
</div>`
: ''}
${!config?.hide_zoom && (actionsZoomIn || actionsZoomOut)
? html` <div class="ptz-zoom">
${renderIcon('zoom_in', 'mdi:plus', actionsZoomIn)}
${renderIcon('zoom_out', 'mdi:minus', actionsZoomOut)}
</div>`
: html``}
${!config?.hide_home && actionsHome
? html`
<div class="ptz-home">${renderIcon('home', 'mdi:home', actionsHome)}</div>
`
: html``}
</div>`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(ptzStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-ptz': FrigateCardPTZ;
}
}
+2 -2
View File
@@ -16,7 +16,6 @@ import {
import basicBlockStyle from '../scss/basic-block.scss';
import { ClipsOrSnapshotsOrAll, ExtendedHomeAssistant } from '../types.js';
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
import { getAllDependentCameras } from '../utils/camera.js';
import { changeViewToRecentEventsForCameraAndDependents } from '../utils/media-to-view';
import { View } from '../view/view.js';
import './surround-basic.js';
@@ -140,7 +139,8 @@ export class FrigateCardSurround extends LitElement {
if (this.view?.is('live')) {
return this.view.isGrid()
? this.cameraManager?.getStore().getVisibleCameraIDs() ?? null
: getAllDependentCameras(this.cameraManager, this.view.camera);
: this.cameraManager?.getStore().getAllDependentCameras(this.view.camera) ??
null;
}
if (this.view.isViewerView()) {
return this.view.query?.getQueryCameraIDs() ?? null;
+2
View File
@@ -15,6 +15,7 @@ import { ExtendedHomeAssistant } from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { dispatchFrigateCardEvent } from '../utils/basic.js';
import { CarouselDirection } from '../utils/embla/carousel-controller.js';
import AutoSize from '../utils/embla/plugins/auto-size/auto-size.js';
import { MediaQueriesResults } from '../view/media-queries-results';
import { View } from '../view/view.js';
import './carousel.js';
@@ -136,6 +137,7 @@ export class FrigateCardThumbnailCarousel extends LitElement {
return html`<frigate-card-carousel
direction=${direction}
.plugins=${[AutoSize()]}
.selected=${this._getSelectedSlide() ?? 0}
.dragFree=${true}
>
+48 -29
View File
@@ -9,10 +9,17 @@ import {
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../camera-manager/manager.js';
import { ConditionsManagerEpoch, getOverridesByKey } from '../card-controller/conditions-manager.js';
import {
ConditionsManagerEpoch,
getOverridesByKey,
} from '../card-controller/conditions-manager.js';
import {
CardWideConfig,
FrigateCardConfig,
RawFrigateCardConfig,
} from '../config/types.js';
import viewsStyle from '../scss/views.scss';
import { ExtendedHomeAssistant } from '../types.js';
import { ConfigManager } from '../card-controller/config-manager.js';
import { ResolvedMediaCache } from '../utils/ha/resolved-media.js';
import { View } from '../view/view.js';
import './surround.js';
@@ -32,7 +39,16 @@ export class FrigateCardViews extends LitElement {
public cameraManager?: CameraManager;
@property({ attribute: false })
public configManager?: ConfigManager;
public nonOverriddenConfig?: FrigateCardConfig;
@property({ attribute: false })
public overriddenConfig?: FrigateCardConfig;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@property({ attribute: false })
public rawConfig?: RawFrigateCardConfig;
@property({ attribute: false })
public resolvedMediaCache?: ResolvedMediaCache;
@@ -94,21 +110,21 @@ export class FrigateCardViews extends LitElement {
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')
!!this.overriddenConfig?.live.preload && !this.view?.is('diagnostics')
);
}
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
// overall views pane to render in ~almost all cases (e.g. for a camera
// initialization error to display, `view` and `cameraConfig` may both be
// undefined, but we still want to render).
if (!this.hass || !config || !nonOverriddenConfig || !cardWideConfig) {
if (
!this.hass ||
!this.overriddenConfig ||
!this.nonOverriddenConfig ||
!this.cardWideConfig
) {
return html``;
}
@@ -122,17 +138,17 @@ export class FrigateCardViews extends LitElement {
};
const thumbnailConfig = this.view?.is('live')
? config.live.controls.thumbnails
? this.overriddenConfig.live.controls.thumbnails
: this.view?.isViewerView()
? config.media_viewer.controls.thumbnails
? this.overriddenConfig.media_viewer.controls.thumbnails
: this.view?.is('timeline')
? config.timeline.controls.thumbnails
? this.overriddenConfig.timeline.controls.thumbnails
: undefined;
const miniTimelineConfig = this.view?.is('live')
? config.live.controls.timeline
? this.overriddenConfig.live.controls.timeline
: this.view?.isViewerView()
? config.media_viewer.controls.timeline
? this.overriddenConfig.media_viewer.controls.timeline
: undefined;
const cameraConfig = this.view
@@ -144,16 +160,16 @@ export class FrigateCardViews extends LitElement {
.hass=${this.hass}
.view=${this.view}
.fetchMedia=${this.view?.is('live')
? config.live.controls.thumbnails.media
? this.overriddenConfig.live.controls.thumbnails.media
: undefined}
.thumbnailConfig=${!this.hide ? thumbnailConfig : undefined}
.timelineConfig=${!this.hide ? miniTimelineConfig : undefined}
.cameraManager=${this.cameraManager}
.cardWideConfig=${cardWideConfig}
.cardWideConfig=${this.cardWideConfig}
>
${!this.hide && this.view?.is('image') && cameraConfig
? html` <frigate-card-image
.imageConfig=${config.image}
.imageConfig=${this.overriddenConfig.image}
.view=${this.view}
.hass=${this.hass}
.cameraConfig=${cameraConfig}
@@ -165,9 +181,9 @@ export class FrigateCardViews extends LitElement {
? html` <frigate-card-gallery
.hass=${this.hass}
.view=${this.view}
.galleryConfig=${config.media_gallery}
.galleryConfig=${this.overriddenConfig.media_gallery}
.cameraManager=${this.cameraManager}
.cardWideConfig=${cardWideConfig}
.cardWideConfig=${this.cardWideConfig}
>
</frigate-card-gallery>`
: ``}
@@ -176,10 +192,10 @@ export class FrigateCardViews extends LitElement {
<frigate-card-viewer
.hass=${this.hass}
.view=${this.view}
.viewerConfig=${config.media_viewer}
.viewerConfig=${this.overriddenConfig.media_viewer}
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
.cardWideConfig=${cardWideConfig}
.cardWideConfig=${this.cardWideConfig}
>
</frigate-card-viewer>
`
@@ -188,16 +204,16 @@ export class FrigateCardViews extends LitElement {
? html` <frigate-card-timeline
.hass=${this.hass}
.view=${this.view}
.timelineConfig=${config.timeline}
.timelineConfig=${this.overriddenConfig.timeline}
.cameraManager=${this.cameraManager}
.cardWideConfig=${cardWideConfig}
.cardWideConfig=${this.cardWideConfig}
>
</frigate-card-timeline>`
: ``}
${!this.hide && this.view?.is('diagnostics')
? html` <frigate-card-diagnostics
.hass=${this.hass}
.rawConfig=${rawConfig}
.rawConfig=${this.rawConfig}
>
</frigate-card-diagnostics>`
: ``}
@@ -213,12 +229,15 @@ export class FrigateCardViews extends LitElement {
<frigate-card-live
.hass=${this.hass}
.view=${this.view}
.nonOverriddenLiveConfig=${nonOverriddenConfig.live}
.overriddenLiveConfig=${config.live}
.nonOverriddenLiveConfig=${this.nonOverriddenConfig.live}
.overriddenLiveConfig=${this.overriddenConfig.live}
.conditionsManagerEpoch=${this.conditionsManagerEpoch}
.liveOverrides=${getOverridesByKey('live', config.overrides)}
.liveOverrides=${getOverridesByKey(
'live',
this.overriddenConfig.overrides,
)}
.cameraManager=${this.cameraManager}
.cardWideConfig=${cardWideConfig}
.cardWideConfig=${this.cardWideConfig}
.microphoneStream=${this.microphoneStream}
class="${classMap(liveClasses)}"
>
+68 -2
View File
@@ -3,7 +3,7 @@ import get from 'lodash-es/get';
import isEqual from 'lodash-es/isEqual';
import set from 'lodash-es/set';
import unset from 'lodash-es/unset';
import { RawFrigateCardConfig } from './config/types';
import { RawFrigateCardConfig, RawFrigateCardConfigArray } from './config/types';
import {
CONF_CAMERAS,
CONF_CAMERAS_GLOBAL_IMAGE,
@@ -303,7 +303,7 @@ export const deleteTransform = function (_value: unknown): number | null | undef
};
// *************************************************************************
// Upgrade Related Functions: Specific Transforms
// Upgrade Related Functions: Specific Transforms / Upgraders
// *************************************************************************
/**
@@ -360,6 +360,71 @@ const frigateUIActionTransform = (data: unknown): boolean => {
return false;
};
/**
* Transform element PTZ to native live PTZ.
* @param data Input data.
* @returns `true` if the configuration was modified.
*/
const upgradePTZElementsToLive = function (): (data: unknown) => boolean {
return function (data: unknown): boolean {
if (
typeof data !== 'object' ||
!data ||
!(CONF_ELEMENTS in data) ||
!Array.isArray(data[CONF_ELEMENTS])
) {
return false;
}
let foundPTZ = false;
const movePTZ = (element: RawFrigateCardConfig): void => {
if (!foundPTZ) {
if (!get(data, 'live.controls.ptz')) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { type: _, ...newPTZ } = element;
set(data, 'live.controls.ptz', newPTZ);
}
foundPTZ = true;
}
};
const processElements = (
elements: RawFrigateCardConfigArray,
): RawFrigateCardConfigArray => {
const newElements: RawFrigateCardConfigArray = [];
for (const element of elements) {
if (element['type'] === 'custom:frigate-card-ptz') {
movePTZ(element);
} else if (
(element['type'] === 'conditional' ||
element['type'] === 'custom:frigate-card-conditional') &&
Array.isArray(element['elements'])
) {
const newConditionalElements = processElements(element['elements']);
if (newConditionalElements.length) {
element['elements'] = newConditionalElements;
newElements.push(element);
}
} else {
newElements.push(element);
}
}
return newElements;
};
const newElements = processElements(data[CONF_ELEMENTS]);
if (foundPTZ) {
if (newElements.length) {
data[CONF_ELEMENTS] = newElements;
} else {
delete data[CONF_ELEMENTS];
}
}
return foundPTZ;
};
};
const UPGRADES = [
// v4.0.0 -> v4.1.0
upgradeArrayOfObjects(
@@ -408,4 +473,5 @@ const UPGRADES = [
typeof data === 'object' && data ? <RawFrigateCardConfig>data : {},
);
},
upgradePTZElementsToLive(),
];
+108 -68
View File
@@ -65,6 +65,19 @@ const MEDIA_ACTION_POSITIVE_CONDITIONS = [
export type AutoUnmuteCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number];
export type AutoPlayCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number];
const PTZ_BASE_ACTIONS = ['left', 'right', 'up', 'down', 'zoom_in', 'zoom_out'] as const;
// PTZ actions as used by the PTZ control (includes a 'home' button).
export const PTZ_CONTROL_ACTIONS = [...PTZ_BASE_ACTIONS, 'home'] as const;
export type PTZControlAction = (typeof PTZ_CONTROL_ACTIONS)[number];
// PTZ actions as used by the camera manager (includes generic presets).
const PTZ_ACTIONS = [...PTZ_BASE_ACTIONS, 'preset'] as const;
export type PTZAction = (typeof PTZ_ACTIONS)[number];
const PTZ_PHASES = ['start', 'stop'] as const;
export type PTZPhase = (typeof PTZ_PHASES)[number];
// *************************************************************************
// View Display Mode
// *************************************************************************
@@ -201,32 +214,22 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [
'camera_ui',
'default',
'diagnostics',
'expand',
'download',
'expand',
'fullscreen',
'menu_toggle',
'mute',
'live_substream_on',
'live_substream_off',
'live_substream_on',
'menu_toggle',
'microphone_mute',
'microphone_unmute',
'play',
'mute',
'pause',
'play',
'screenshot',
'unmute',
] as const;
export type FrigateCardGeneralAction = (typeof FRIGATE_CARD_GENERAL_ACTIONS)[number];
const FRIGATE_CARD_ACTIONS = [
...FRIGATE_CARD_VIEWS_USER_SPECIFIED,
...FRIGATE_CARD_GENERAL_ACTIONS,
'camera_select',
'live_substream_select',
'media_player',
'display_mode_select',
] as const;
export type FrigateCardAction = (typeof FRIGATE_CARD_ACTIONS)[number];
const frigateCardViewActionSchema = frigateCardCustomActionsBaseSchema.extend({
frigate_card_action: z.enum(FRIGATE_CARD_VIEWS_USER_SPECIFIED),
});
@@ -240,9 +243,6 @@ const frigateCardCameraSelectActionSchema = frigateCardCustomActionsBaseSchema.e
frigate_card_action: z.literal('camera_select'),
camera: z.string(),
});
export type FrigateCardCameraSelectAction = z.infer<
typeof frigateCardCameraSelectActionSchema
>;
const frigateCardLiveDependencySelectActionSchema =
frigateCardCustomActionsBaseSchema.extend({
@@ -263,6 +263,19 @@ const frigateCardViewDisplayModeActionSchema = frigateCardCustomActionsBaseSchem
},
);
const frigateCardPTZActionSchema = frigateCardCustomActionsBaseSchema.extend({
frigate_card_action: z.literal('ptz'),
ptz_action: z.enum(PTZ_ACTIONS),
ptz_phase: z.enum(PTZ_PHASES).optional(),
ptz_preset: z.string().optional(),
});
export type FrigateCardPTZAction = z.infer<typeof frigateCardPTZActionSchema>;
const frigateCardShowPTZActionSchema = frigateCardCustomActionsBaseSchema.extend({
frigate_card_action: z.literal('show_ptz'),
show_ptz: z.boolean(),
});
export const frigateCardCustomActionSchema = z.union([
frigateCardViewActionSchema,
frigateCardGeneralActionSchema,
@@ -270,6 +283,8 @@ export const frigateCardCustomActionSchema = z.union([
frigateCardLiveDependencySelectActionSchema,
frigateCardMediaPlayerActionSchema,
frigateCardViewDisplayModeActionSchema,
frigateCardPTZActionSchema,
frigateCardShowPTZActionSchema,
]);
export type FrigateCardCustomAction = z.infer<typeof frigateCardCustomActionSchema>;
@@ -481,53 +496,8 @@ export const frigateConditionalSchema = z.object({
conditions: frigateCardConditionSchema,
elements: z.lazy(() => pictureElementsSchema),
});
export type FrigateConditional = z.infer<typeof frigateConditionalSchema>;
// *************************************************************************
// Custom Element Configuration: PTZ
// *************************************************************************
export const frigateCardPTZSchema = z.preprocess(
// To avoid lots of YAML duplication, provide an easy way to just specify the
// service data as actions for each PTZ icon, and it will be preprocessed into
// the full form. This also provides compatability with the AlexIT/WebRTC PTZ
// configuration.
(data) => {
if (!data || typeof data !== 'object' || !data['service']) {
return data;
}
const out = { ...data };
['left', 'right', 'up', 'down', 'zoom_in', 'zoom_out', 'home'].forEach((name) => {
if (`data_${name}` in data && !(`actions_${name}` in data)) {
out[`actions_${name}`] = {
tap_action: {
action: 'call-service',
service: data['service'],
data: data[`data_${name}`],
},
};
delete out[`data_${name}`];
}
});
return out;
},
z.object({
type: z.literal('custom:frigate-card-ptz'),
style: z.object({}).passthrough().optional(),
orientation: z.enum(['vertical', 'horizontal']).default('vertical').optional(),
service: z.string().optional(),
actions_left: actionsBaseSchema.optional(),
actions_right: actionsBaseSchema.optional(),
actions_up: actionsBaseSchema.optional(),
actions_down: actionsBaseSchema.optional(),
actions_zoom_in: actionsBaseSchema.optional(),
actions_zoom_out: actionsBaseSchema.optional(),
actions_home: actionsBaseSchema.optional(),
}),
);
export type FrigateCardPTZConfig = z.infer<typeof frigateCardPTZSchema>;
// *************************************************************************
// Custom Element Configuration: Stock Picture Elements + Custom
// *************************************************************************
@@ -540,7 +510,6 @@ const pictureElementSchema = z.union([
menuSubmenuSchema,
menuSubmenuSelectSchema,
frigateConditionalSchema,
frigateCardPTZSchema,
stateBadgeIconSchema,
stateIconSchema,
stateLabelSchema,
@@ -818,6 +787,73 @@ const jsmpegConfigSchema = z.object({
.optional(),
});
const frigateCardPTZActions = z.object({
actions_left: actionsBaseSchema.optional(),
actions_right: actionsBaseSchema.optional(),
actions_up: actionsBaseSchema.optional(),
actions_down: actionsBaseSchema.optional(),
actions_zoom_in: actionsBaseSchema.optional(),
actions_zoom_out: actionsBaseSchema.optional(),
actions_home: actionsBaseSchema.optional(),
});
export type FrigateCardPTZActions = z.infer<typeof frigateCardPTZActions>;
const livePTZControlsDefaults = {
orientation: 'horizontal' as const,
mode: 'on' as const,
hide_pan_tilt: false,
hide_zoom: false,
hide_home: false,
position: 'bottom-right' as const,
};
export const frigateCardPTZSchema = z.preprocess(
// To avoid lots of YAML duplication, provide an easy way to just specify the
// service data as actions for each PTZ icon, and it will be preprocessed into
// the full form. This also provides compatability with the AlexIT/WebRTC PTZ
// configuration.
(data) => {
if (!data || typeof data !== 'object' || !data['service']) {
return data;
}
const out = { ...data };
PTZ_CONTROL_ACTIONS.forEach((name) => {
if (`data_${name}` in data && !(`actions_${name}` in data)) {
out[`actions_${name}`] = {
tap_action: {
action: 'call-service',
service: data['service'],
data: data[`data_${name}`],
},
};
delete out[`data_${name}`];
}
});
return out;
},
frigateCardPTZActions.extend({
// TODO strip from config update
// type: z.literal('custom:frigate-card-ptz'),
mode: z.enum(['off', 'on']).default(livePTZControlsDefaults.mode),
position: z
.enum(['top-left', 'top-right', 'bottom-left', 'bottom-right'])
.default(livePTZControlsDefaults.position),
orientation: z
.enum(['vertical', 'horizontal'])
.default(livePTZControlsDefaults.orientation),
hide_pan_tilt: z.boolean().default(livePTZControlsDefaults.hide_pan_tilt),
hide_zoom: z.boolean().default(livePTZControlsDefaults.hide_zoom),
hide_home: z.boolean().default(livePTZControlsDefaults.hide_home),
service: z.string().optional(),
style: z.object({}).passthrough().optional(),
}),
);
export type FrigateCardPTZConfig = z.infer<typeof frigateCardPTZSchema>;
const liveThumbnailControlsDefaults = {
...thumbnailControlsDefaults,
media: 'all' as const,
@@ -842,6 +878,7 @@ const liveConfigDefault = {
size: 48,
style: 'chevrons' as const,
},
ptz: livePTZControlsDefaults,
thumbnails: liveThumbnailControlsDefaults,
timeline: miniTimelineConfigDefault,
},
@@ -872,6 +909,7 @@ const liveOverridableConfigSchema = z
),
})
.default(liveConfigDefault.controls.next_previous),
ptz: frigateCardPTZSchema.default(liveConfigDefault.controls.ptz),
thumbnails: livethumbnailsControlSchema.default(
liveConfigDefault.controls.thumbnails,
),
@@ -934,7 +972,7 @@ const castConfigDefault = {
method: 'standard' as const,
};
export const castSchema = z.object({
const castSchema = z.object({
method: z.enum(['standard', 'dashboard']).default(castConfigDefault.method).optional(),
dashboard: z
.object({
@@ -1170,6 +1208,7 @@ const menuConfigDefault = {
recordings: hiddenButtonDefault,
screenshot: hiddenButtonDefault,
display_mode: visibleButtonDefault,
ptz: hiddenButtonDefault,
},
button_size: 40,
};
@@ -1220,6 +1259,7 @@ const menuConfigSchema = z
display_mode: visibleButtonSchema.default(
menuConfigDefault.buttons.display_mode,
),
ptz: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz),
})
.default(menuConfigDefault.buttons),
button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),
@@ -1411,13 +1451,13 @@ const overridesSchema = z
// Automation Configuration
// *************************************************************************
const automationActionSchema = actionSchema.array().optional();
const automationActionSchema = actionSchema.array();
export type AutomationActions = z.infer<typeof automationActionSchema>;
const automationSchema = z.object({
conditions: frigateCardConditionSchema,
actions: automationActionSchema,
actions_not: automationActionSchema,
actions: automationActionSchema.optional(),
actions_not: automationActionSchema.optional(),
});
export type Automation = z.infer<typeof automationSchema>;
+12 -8
View File
@@ -98,7 +98,7 @@ export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SIZE =
`${CONF_MEDIA_GALLERY}.controls.thumbnails.size` as const;
export const CONF_MEDIA_VIEWER = 'media_viewer' as const;
const CONF_MEDIA_VIEWER = 'media_viewer' as const;
export const CONF_MEDIA_VIEWER_AUTO_PLAY = `${CONF_MEDIA_VIEWER}.auto_play` as const;
export const CONF_MEDIA_VIEWER_AUTO_PAUSE = `${CONF_MEDIA_VIEWER}.auto_pause` as const;
export const CONF_MEDIA_VIEWER_AUTO_MUTE = `${CONF_MEDIA_VIEWER}.auto_mute` as const;
@@ -169,6 +169,17 @@ export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE =
`${CONF_LIVE}.controls.next_previous.style` as const;
export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE =
`${CONF_LIVE}.controls.next_previous.size` as const;
export const CONF_LIVE_CONTROLS_PTZ_HIDE_HOME =
`${CONF_LIVE}.controls.ptz.hide_home` as const;
export const CONF_LIVE_CONTROLS_PTZ_HIDE_PAN_TILT =
`${CONF_LIVE}.controls.ptz.hide_pan_tilt` as const;
export const CONF_LIVE_CONTROLS_PTZ_HIDE_ZOOM =
`${CONF_LIVE}.controls.ptz.hide_zoom` as const;
export const CONF_LIVE_CONTROLS_PTZ_MODE = `${CONF_LIVE}.controls.ptz.mode` as const;
export const CONF_LIVE_CONTROLS_PTZ_ORIENTATION =
`${CONF_LIVE}.controls.ptz.orientation` as const;
export const CONF_LIVE_CONTROLS_PTZ_POSITION =
`${CONF_LIVE}.controls.ptz.position` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA =
`${CONF_LIVE}.controls.thumbnails.media` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_MODE =
@@ -257,17 +268,10 @@ export const CONF_MENU_STYLE = `${CONF_MENU}.style` as const;
export const CONF_MENU_BUTTON_SIZE = `${CONF_MENU}.button_size` as const;
export const CONF_MENU_BUTTONS = `${CONF_MENU}.buttons` as const;
export const CONF_MENU_BUTTONS_CAMERAS = `${CONF_MENU}.buttons.cameras` as const;
export const CONF_MENU_BUTTONS_CLIPS = `${CONF_MENU}.buttons.clips` as const;
export const CONF_MENU_BUTTONS_DOWNLOAD = `${CONF_MENU}.buttons.download` as const;
export const CONF_MENU_BUTTONS_FRIGATE = `${CONF_MENU}.buttons.frigate` as const;
export const CONF_MENU_BUTTONS_CAMERA_UI = `${CONF_MENU}.buttons.camera_ui` as const;
export const CONF_MENU_BUTTONS_FULLSCREEN = `${CONF_MENU}.buttons.fullscreen` as const;
export const CONF_MENU_BUTTONS_IMAGE = `${CONF_MENU}.buttons.image` as const;
export const CONF_MENU_BUTTONS_LIVE = `${CONF_MENU}.buttons.live` as const;
export const CONF_MENU_BUTTONS_MEDIA_PLAYER =
`${CONF_MENU}.buttons.media_player` as const;
export const CONF_MENU_BUTTONS_SNAPSHOTS = `${CONF_MENU}.buttons.snapshots` as const;
export const CONF_MENU_BUTTONS_TIMELINE = `${CONF_MENU}.buttons.timeline` as const;
const CONF_DIMENSIONS = 'dimensions' as const;
+89
View File
@@ -80,6 +80,12 @@ import {
CONF_LIVE_CONTROLS_BUILTIN,
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE,
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE,
CONF_LIVE_CONTROLS_PTZ_HIDE_HOME,
CONF_LIVE_CONTROLS_PTZ_HIDE_PAN_TILT,
CONF_LIVE_CONTROLS_PTZ_HIDE_ZOOM,
CONF_LIVE_CONTROLS_PTZ_MODE,
CONF_LIVE_CONTROLS_PTZ_ORIENTATION,
CONF_LIVE_CONTROLS_PTZ_POSITION,
CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA,
CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
@@ -211,6 +217,7 @@ const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card';
const MENU_IMAGE_LAYOUT = 'image.layout';
const MENU_LIVE_CONTROLS = 'live.controls';
const MENU_LIVE_CONTROLS_NEXT_PREVIOUS = 'live.controls.next_previous';
const MENU_LIVE_CONTROLS_PTZ = 'live.controls.ptz';
const MENU_LIVE_CONTROLS_THUMBNAILS = 'live.controls.thumbnails';
const MENU_LIVE_CONTROLS_TIMELINE = 'live.controls.timeline';
const MENU_LIVE_CONTROLS_TITLE = 'live.controls.title';
@@ -575,6 +582,45 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
{ value: 'dashboard', label: localize('config.cameras.cast.methods.dashboard') },
];
protected _ptzModes: EditorSelectOption[] = [
{ value: '', label: '' },
{ value: 'on', label: localize('config.live.controls.ptz.modes.on') },
{ value: 'off', label: localize('config.live.controls.ptz.modes.off') },
{ value: 'auto', label: localize('config.live.controls.ptz.modes.auto') },
];
protected _ptzOrientations: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'vertical',
label: localize('config.live.controls.ptz.orientations.vertical'),
},
{
value: 'horizontal',
label: localize('config.live.controls.ptz.orientations.horizontal'),
},
];
protected _ptzPositions: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'top-left',
label: localize('config.live.controls.ptz.positions.top-left'),
},
{
value: 'top-right',
label: localize('config.live.controls.ptz.positions.top-right'),
},
{
value: 'bottom-left',
label: localize('config.live.controls.ptz.positions.bottom-left'),
},
{
value: 'bottom-right',
label: localize('config.live.controls.ptz.positions.bottom-right'),
},
];
public setConfig(config: RawFrigateCardConfig): void {
// Note: This does not use Zod to parse the configuration, so it may be
// partially or completely invalid. It's more useful to have a partially
@@ -1886,6 +1932,8 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${this._renderMenuButton('play') /* */}
${this._renderMenuButton('mute') /* */}
${this._renderMenuButton('screenshot')}
${this._renderMenuButton('display_mode')}
${this._renderMenuButton('ptz')}
</div>
`
: ''}
@@ -1981,6 +2029,47 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS,
this._defaults.live.controls.timeline.show_recordings,
)}
${this._putInSubmenu(
MENU_LIVE_CONTROLS_PTZ,
true,
'config.live.controls.ptz.editor_label',
{ name: 'mdi:pan' },
html`
${this._renderOptionSelector(
CONF_LIVE_CONTROLS_PTZ_MODE,
this._ptzModes,
)}
${this._renderOptionSelector(
CONF_LIVE_CONTROLS_PTZ_POSITION,
this._ptzPositions,
)}
${this._renderOptionSelector(
CONF_LIVE_CONTROLS_PTZ_ORIENTATION,
this._ptzOrientations,
)}
${this._renderSwitch(
CONF_LIVE_CONTROLS_PTZ_HIDE_PAN_TILT,
this._defaults.live.controls.ptz.hide_pan_tilt,
{
label: localize('config.live.controls.ptz.hide_pan_tilt'),
},
)}
${this._renderSwitch(
CONF_LIVE_CONTROLS_PTZ_HIDE_ZOOM,
this._defaults.live.controls.ptz.hide_pan_tilt,
{
label: localize('config.live.controls.ptz.hide_zoom'),
},
)}
${this._renderSwitch(
CONF_LIVE_CONTROLS_PTZ_HIDE_HOME,
this._defaults.live.controls.ptz.hide_home,
{
label: localize('config.live.controls.ptz.hide_home'),
},
)}
`,
)}
`,
)}
${this._renderMediaLayout(
+26 -1
View File
@@ -233,7 +233,31 @@
"auto_play": "Automatically play live cameras",
"auto_unmute": "Automatically unmute live cameras",
"controls": {
"editor_label": "Live Controls"
"editor_label": "Live Controls",
"ptz": {
"editor_label": "PTZ",
"mode": "Mode",
"modes": {
"on": "On",
"off": "Off",
"auto": "Automatic"
},
"orientation": "Orientation",
"orientations": {
"vertical": "Vertical",
"horizontal": "Horizontal"
},
"position": "Position",
"positions": {
"top-left": "Top left",
"top-right": "Top right",
"bottom-left": "Bottom left",
"bottom-right": "Bottom right"
},
"hide_zoom": "Hide zoom control",
"hide_pan_tilt": "Hide pan & tilt control",
"hide_home": "Hide home control"
}
},
"draggable": "Live cameras view can be dragged/swiped",
"layout": "Live Layout",
@@ -301,6 +325,7 @@
"mute": "Mute / Unmute",
"play": "Play / Pause",
"priority": "Priority",
"ptz": "Show PTZ controls",
"recordings": "Recordings",
"screenshot": "Screenshot",
"snapshots": "Snapshots",
+27 -2
View File
@@ -233,7 +233,31 @@
"auto_play": "Gioca automaticamente le telecamere dal vivo",
"auto_unmute": "Riattiva automaticamente l'audio delle telecamere live",
"controls": {
"editor_label": "Controlli dal vivo"
"editor_label": "Controlli dal vivo",
"ptz": {
"editor_label": "",
"mode": "",
"modes": {
"on": "",
"off": "",
"auto": ""
},
"orientation": "",
"orientations": {
"vertical": "",
"horizontal": ""
},
"position": "",
"positions": {
"top-left": "",
"top-right": "",
"bottom-left": "",
"bottom-right": ""
},
"hide_zoom": "",
"hide_pan_tilt": "",
"hide_home": ""
}
},
"draggable": "Il Visualizzatore eventi può essere trascinato oppure puoi scorrere",
"layout": "Disposizione dal vivo",
@@ -299,6 +323,7 @@
"mute": "",
"play": "",
"priority": "Priorità",
"ptz": "",
"screenshot": "",
"snapshots": "Istantanee",
"substreams": "Flusso/i secondario/i",
@@ -523,4 +548,4 @@
},
"select_date": "Scegli la data"
}
}
}
+26 -1
View File
@@ -233,7 +233,31 @@
"auto_play": "Reproduzir câmeras ao vivo automaticamente",
"auto_unmute": "Ativar automaticamente o som das câmeras ao vivo",
"controls": {
"editor_label": "Controles da visualização ao vivo"
"editor_label": "Controles da visualização ao vivo",
"ptz": {
"editor_label": "",
"mode": "",
"modes": {
"on": "",
"off": "",
"auto": ""
},
"orientation": "",
"orientations": {
"vertical": "",
"horizontal": ""
},
"position": "",
"positions": {
"top-left": "",
"top-right": "",
"bottom-left": "",
"bottom-right": ""
},
"hide_zoom": "",
"hide_pan_tilt": "",
"hide_home": ""
}
},
"draggable": "A visualização ao vivo das câmeras pode ser arrastada/deslizada",
"layout": "Layout dinâmico",
@@ -300,6 +324,7 @@
"mute": "",
"play": "",
"priority": "Prioridade",
"ptz": "",
"recordings": "Gravações",
"screenshot": "",
"snapshots": "Instantâneos",
+26 -1
View File
@@ -226,7 +226,31 @@
"auto_play": "Reproduzir câmeras ao vivo automaticamente",
"auto_unmute": "Ativar automaticamente o som das câmeras ao vivo",
"controls": {
"editor_label": "Controles da visualização ao vivo"
"editor_label": "Controles da visualização ao vivo",
"ptz": {
"editor_label": "",
"mode": "",
"modes": {
"on": "",
"off": "",
"auto": ""
},
"orientation": "",
"orientations": {
"vertical": "",
"horizontal": ""
},
"position": "",
"positions": {
"top-left": "",
"top-right": "",
"bottom-left": "",
"bottom-right": ""
},
"hide_zoom": "",
"hide_pan_tilt": "",
"hide_home": ""
}
},
"draggable": "A visualização ao vivo das câmeras pode ser arrastada/deslizada",
"layout": "layout",
@@ -292,6 +316,7 @@
"mute": "",
"play": "",
"priority": "Prioridade",
"ptz": "",
"screenshot": "",
"snapshots": "Instantâneos",
"substreams": "substreams",
+2 -1
View File
@@ -52,7 +52,7 @@ export function getLanguage(hass?: HomeAssistant): string {
/**
* Load required languages.
*/
export const loadLanguages = async (hass: HomeAssistant): Promise<void> => {
export const loadLanguages = async (hass: HomeAssistant): Promise<boolean> => {
const lang = getLanguage(hass);
if (lang === 'it') {
languages[lang] = await import('./languages/it.json');
@@ -65,6 +65,7 @@ export const loadLanguages = async (hass: HomeAssistant): Promise<void> => {
if (lang) {
frigateCardLanguage = lang;
}
return true;
};
/**
+3 -2
View File
@@ -12,11 +12,11 @@
import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js';
import { query } from 'lit/decorators/query.js';
import { screenshotMedia } from '../utils/screenshot.js';
import { dispatchErrorMessageEvent } from '../components/message.js';
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
import { FrigateCardMediaPlayer } from '../types.js';
import { mayHaveAudio } from '../utils/audio.js';
import { errorToConsole } from '../utils/basic.js';
import {
dispatchMediaLoadedEvent,
dispatchMediaPauseEvent,
@@ -26,6 +26,7 @@ import {
import {
hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, setControlsOnVideo
} from '../utils/media.js';
import { screenshotMedia } from '../utils/screenshot.js';
customElements.whenDefined('ha-hls-player').then(() => {
@customElement('frigate-card-ha-hls-player')
@@ -97,7 +98,7 @@ customElements.whenDefined('ha-hls-player').then(() => {
// Use native Frigate card error handling for fatal errors.
return dispatchErrorMessageEvent(this, this._error);
} else {
console.error(this._error);
errorToConsole(this._error, console.error);
}
}
return html`
+1 -2
View File
@@ -9,6 +9,5 @@ ha-icon {
position: absolute;
top: 10px;
right: 10px;
opacity: 50%;
color: white;
color: var(--primary-color);
}
@@ -1,7 +1,7 @@
// Modified from / inspired by:
// Inspired by:
// https://github.com/AlexxIT/WebRTC/blob/master/custom_components/webrtc/www/webrtc-camera.js
:host {
position: relative;
position: absolute;
width: fit-content;
height: fit-content;
@@ -9,6 +9,19 @@
--frigate-card-ptz-icon-size: 24px;
}
:host([data-position$='-left']) {
left: 5%;
}
:host([data-position$='-right']) {
right: 5%;
}
:host([data-position^='top-']) {
top: 5%;
}
:host([data-position^='bottom-']) {
bottom: 5%;
}
/*****************
* Main Containers
*****************/
+77 -56
View File
@@ -7,10 +7,10 @@ import {
import {
Actions,
ActionType,
FrigateCardAction,
FrigateCardCustomAction,
frigateCardCustomActionSchema,
ViewDisplayMode,
FrigateCardGeneralAction,
FrigateCardUserSpecifiedView,
} from '../config/types.js';
/**
@@ -30,59 +30,75 @@ export function convertActionToFrigateCardCustomAction(
return parseResult.success ? parseResult.data : null;
}
/**
* Create a Frigate card custom action.
* @param action The Frigate card action string (e.g. 'fullscreen')
* @returns A FrigateCardCustomAction for that action string or null.
*/
export function createFrigateCardCustomAction(
action: FrigateCardAction,
args?: {
export function createFrigateCardSimpleAction(
action: FrigateCardGeneralAction | FrigateCardUserSpecifiedView,
options?: {
cardID?: string;
camera?: string;
media_player?: string;
media_player_action?: 'play' | 'stop';
display_mode?: ViewDisplayMode;
},
): FrigateCardCustomAction | null {
if (action === 'camera_select' || action === 'live_substream_select') {
if (!args?.camera) {
return null;
}
return {
action: 'fire-dom-event',
frigate_card_action: action,
camera: args.camera as string,
...(args.cardID && { card_id: args.cardID }),
};
}
if (action === 'media_player') {
if (!args?.media_player || !args.media_player_action) {
return null;
}
return {
action: 'fire-dom-event',
frigate_card_action: action,
media_player: args.media_player,
media_player_action: args.media_player_action,
...(args.cardID && { card_id: args.cardID }),
};
}
if (action === 'display_mode_select') {
if (!args?.display_mode) {
return null;
}
return {
action: 'fire-dom-event',
frigate_card_action: action,
display_mode: args?.display_mode,
...(args.cardID && { card_id: args.cardID }),
};
}
return {
action: 'fire-dom-event',
frigate_card_action: action,
...(args?.cardID && { card_id: args.cardID }),
...(options?.cardID && { card_id: options.cardID }),
};
}
export function createFrigateCardCameraAction(
action: 'camera_select' | 'live_substream_select',
camera: string,
options?: {
cardID?: string;
},
): FrigateCardCustomAction {
return {
action: 'fire-dom-event',
frigate_card_action: action,
camera: camera,
...(options?.cardID && { card_id: options.cardID }),
};
}
export function createFrigateCardMediaPlayerAction(
mediaPlayer: string,
mediaPlayerAction: 'play' | 'stop',
options?: {
cardID?: string;
},
): FrigateCardCustomAction {
return {
action: 'fire-dom-event',
frigate_card_action: 'media_player',
media_player: mediaPlayer,
media_player_action: mediaPlayerAction,
...(options?.cardID && { card_id: options.cardID }),
};
}
export function createFrigateCardDisplayModeAction(
displayMode: 'single' | 'grid',
options?: {
cardID?: string;
},
): FrigateCardCustomAction {
return {
action: 'fire-dom-event',
frigate_card_action: 'display_mode_select',
display_mode: displayMode,
...(options?.cardID && { card_id: options.cardID }),
};
}
export function createFrigateCardShowPTZAction(
showPTZ: boolean,
options?: {
cardID?: string;
},
): FrigateCardCustomAction {
return {
action: 'fire-dom-event',
frigate_card_action: 'show_ptz',
show_ptz: showPTZ,
...(options?.cardID && { card_id: options.cardID }),
};
}
@@ -94,10 +110,10 @@ export function createFrigateCardCustomAction(
*/
export function getActionConfigGivenAction(
interaction?: string,
config?: Actions,
): ActionType | ActionType[] | undefined {
config?: Actions | null,
): ActionType | ActionType[] | null {
if (!interaction || !config) {
return undefined;
return null;
}
if (interaction == 'tap' && config.tap_action) {
return config.tap_action;
@@ -110,7 +126,7 @@ export function getActionConfigGivenAction(
} else if (interaction == 'start_tap' && config.start_tap_action) {
return config.start_tap_action;
}
return undefined;
return null;
}
/**
@@ -132,7 +148,7 @@ export const frigateCardHandleActionConfig = (
entity?: string;
},
action: string,
actionConfig?: ActionType | ActionType[],
actionConfig?: ActionType | ActionType[] | null,
): boolean => {
// Only allow a tap action to use a default non-config (the more-info config).
if (actionConfig || action == 'tap') {
@@ -149,7 +165,7 @@ export const frigateCardHandleAction = (
camera_image?: string;
entity?: string;
},
actionConfig: ActionType | ActionType[] | undefined,
actionConfig?: ActionType | ActionType[] | null,
): void => {
// ActionConfig vs ActionType:
// * There is a slight typing (but not functional) difference between
@@ -161,7 +177,12 @@ export const frigateCardHandleAction = (
handleActionConfig(node, hass, config, action as ActionConfig | undefined),
);
} else {
handleActionConfig(node, hass, config, actionConfig as ActionConfig | undefined);
handleActionConfig(
node,
hass,
config,
(actionConfig ?? undefined) as ActionConfig | undefined,
);
}
};
-46
View File
@@ -1,4 +1,3 @@
import { CameraManager } from '../camera-manager/manager.js';
import { CameraConfig, RawFrigateCardConfig } from '../config/types.js';
/**
@@ -30,48 +29,3 @@ export function getCameraID(
''
);
}
/**
* Get all cameras that depend on a given camera.
* @param cameraManager The camera manager.
* @param cameraID ID of the target camera.
* @returns A set of dependent cameraIDs or null (since JS sets guarantee order,
* the first item in the set is guaranteed to be the cameraID itself).
*/
export function getAllDependentCameras(
cameraManager: CameraManager,
cameraID: string,
): Set<string>;
export function getAllDependentCameras(
cameraManager?: CameraManager,
cameraID?: string,
): Set<string> | null;
export function getAllDependentCameras(
cameraManager?: CameraManager,
cameraID?: string,
): Set<string> | null {
if (!cameraManager || !cameraID) {
return null;
}
const cameras = cameraManager.getStore().getCameras();
const cameraIDs: Set<string> = new Set();
const getDependentCameras = (cameraID: string): void => {
const cameraConfig = cameras.get(cameraID);
if (cameraConfig) {
cameraIDs.add(cameraID);
const dependentCameras: Set<string> = new Set();
cameraConfig.dependencies.cameras.forEach((item) => dependentCameras.add(item));
if (cameraConfig.dependencies.all_cameras) {
cameras.forEach((_, key) => dependentCameras.add(key));
}
for (const eventCameraID of dependentCameras) {
if (!cameraIDs.has(eventCameraID)) {
getDependentCameras(eventCameraID);
}
}
}
};
getDependentCameras(cameraID);
return cameraIDs;
}
+6 -3
View File
@@ -5,7 +5,7 @@ enum InitializationState {
INITIALIZED = 'initialized',
}
type InitializationCallback = () => Promise<unknown>;
type InitializationCallback = () => Promise<boolean>;
/**
* Manages initialization state & calling initializers.
@@ -43,8 +43,11 @@ export class Initializer {
if (state !== InitializationState.INITIALIZING) {
if (initializer) {
this._state.set(aspect, InitializationState.INITIALIZING);
await initializer();
this._state.set(aspect, InitializationState.INITIALIZED);
if (await initializer()) {
this._state.set(aspect, InitializationState.INITIALIZED);
} else {
this.uninitialize(aspect);
}
} else {
this._state.set(aspect, InitializationState.INITIALIZED);
}
+2 -3
View File
@@ -14,7 +14,6 @@ import {
import { MediaQueriesResults } from '../view/media-queries-results';
import { View } from '../view/view';
import { errorToConsole } from './basic';
import { getAllDependentCameras } from './camera.js';
type ResultSelectType = 'latest' | 'time' | 'none';
@@ -32,7 +31,7 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
): Promise<void> => {
const cameraIDs = options?.allCameras
? cameraManager.getStore().getVisibleCameraIDs()
: getAllDependentCameras(cameraManager, view.camera);
: cameraManager.getStore().getAllDependentCameras(view.camera);
if (!cameraIDs.size) {
return;
}
@@ -98,7 +97,7 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
): Promise<void> => {
const cameraIDs = options?.allCameras
? cameraManager.getStore().getVisibleCameraIDs()
: getAllDependentCameras(cameraManager, view.camera);
: cameraManager.getStore().getAllDependentCameras(view.camera);
if (!cameraIDs.size) {
return;
}
+14
View File
@@ -0,0 +1,14 @@
import { CameraManagerCameraCapabilities } from '../camera-manager/types';
import { FrigateCardPTZConfig, PTZ_CONTROL_ACTIONS } from '../config/types';
export const hasUsablePTZ = (
capabilities: CameraManagerCameraCapabilities | null,
config: FrigateCardPTZConfig,
): boolean => {
for (const actionName of PTZ_CONTROL_ACTIONS) {
if ('actions_' + actionName in config) {
return true;
}
}
return !!capabilities?.ptz;
};
+2 -1
View File
@@ -5,6 +5,7 @@ import { dispatchFrigateCardEvent } from '../utils/basic.js';
import { MediaQueries } from './media-queries';
import { MediaQueriesClassifier } from './media-queries-classifier.js';
import { MediaQueriesResults } from './media-queries-results';
import merge from 'lodash-es/merge';
interface ViewEvolveParameters {
view?: FrigateCardView;
@@ -177,7 +178,7 @@ export class View {
* @returns This view.
*/
public mergeInContext(context?: ViewContext): View {
this.context = { ...this.context, ...context };
this.context = merge(this.context ?? {}, this.context, context);
return this;
}