Allow camera capabilities to be configured.

This commit is contained in:
Dermot Duffy
2024-03-26 21:00:15 -07:00
parent 0c7260d95a
commit 95c44adcf1
63 changed files with 2308 additions and 1447 deletions
@@ -14,6 +14,7 @@ import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media'
import { ViewMedia } from '../../view/media';
import { RequestCache } from '../cache';
import { Camera } from '../camera';
import { Capabilities } from '../capabilities';
import { CameraManagerEngine } from '../engine';
import { GenericCameraManagerEngine } from '../generic/engine-generic';
import { rangesOverlap } from '../range';
@@ -142,15 +143,23 @@ export class BrowseMediaCameraManagerEngine
cameraConfig: CameraConfig,
): Promise<Camera> {
const camera = new BrowseMediaCamera(cameraConfig, this, {
capabilities: {
canFavoriteEvents: false,
canFavoriteRecordings: false,
canSeek: false,
supportsClips: true,
supportsRecordings: false,
supportsSnapshots: true,
supportsTimeline: true,
},
capabilities: new Capabilities(
{
'favorite-events': false,
'favorite-recordings': false,
clips: true,
live: true,
menu: true,
recordings: false,
seek: false,
snapshots: true,
substream: true,
},
{
disable: cameraConfig.capabilities?.disable,
disableExcept: cameraConfig.capabilities?.disable_except,
},
),
eventCallback: this._eventCallback,
});
return await camera.initialize(hass, entityRegistryManager);
+5 -4
View File
@@ -10,14 +10,15 @@ import {
import { EntityRegistryManager } from '../utils/ha/entity-registry';
import { CameraManagerEngine } from './engine';
import { CameraNoIDError } from './error';
import { CameraEventCallback, CameraManagerCameraCapabilities } from './types';
import { CameraEventCallback } from './types';
import { Capabilities } from './capabilities';
type DestroyCallback = () => Promise<void>;
export class Camera {
protected _config: CameraConfig;
protected _engine: CameraManagerEngine;
protected _capabilities?: CameraManagerCameraCapabilities;
protected _capabilities?: Capabilities;
protected _eventCallback?: CameraEventCallback;
protected _destroyCallbacks: DestroyCallback[] = [];
@@ -25,7 +26,7 @@ export class Camera {
config: CameraConfig,
engine: CameraManagerEngine,
options?: {
capabilities?: CameraManagerCameraCapabilities;
capabilities?: Capabilities;
eventCallback?: CameraEventCallback;
},
) {
@@ -97,7 +98,7 @@ export class Camera {
return this._engine;
}
public getCapabilities(): CameraManagerCameraCapabilities | null {
public getCapabilities(): Capabilities | null {
return this._capabilities ?? null;
}
}
+60
View File
@@ -0,0 +1,60 @@
import {
CapabilitiesRaw,
CapabilityKey,
capabilityKeys,
PTZCapabilities,
} from '../types';
import { CapabilitySearchOptions } from './types';
export class Capabilities {
private _capabilities: CapabilitiesRaw;
constructor(
capabilities: CapabilitiesRaw,
options?: {
disable?: CapabilityKey[];
disableExcept?: CapabilityKey[];
},
) {
this._capabilities = capabilities;
for (const key of options?.disable ?? []) {
this._disable(key);
}
for (const key of capabilityKeys) {
if (options?.disableExcept?.length && !options.disableExcept.includes(key)) {
this._disable(key);
}
}
}
protected _disable(capability: CapabilityKey): void {
delete this._capabilities[capability];
}
public matches(capability: CapabilitySearchOptions): boolean {
let result = true;
if (typeof capability === 'string') {
result &&= this.has(capability);
}
if (typeof capability === 'object' && capability.allCapabilities) {
result &&= capability.allCapabilities.every((capability) => this.has(capability));
}
if (typeof capability === 'object' && capability.anyCapabilities) {
result &&= capability.anyCapabilities.some((capability) => this.has(capability));
}
return result;
}
public has(capability: CapabilityKey): boolean {
return !!this._capabilities[capability];
}
public getPTZCapabilities(): PTZCapabilities | null {
return this._capabilities.ptz ?? null;
}
public getRawCapabilities(): CapabilitiesRaw {
return this._capabilities;
}
}
+5 -2
View File
@@ -10,7 +10,7 @@ import { MemoryRequestCache, RecordingSegmentsCache, RequestCache } from './cach
import { CameraManagerEngine } from './engine';
import { CameraInitializationError } from './error';
import { CameraEventCallback, Engine } from './types';
import { getCameraEntityFromConfig } from './utils';
import { getCameraEntityFromConfig } from './utils/camera-entity-from-config';
export class CameraManagerEngineFactory {
protected _entityRegistryManager: EntityRegistryManager;
@@ -24,7 +24,10 @@ export class CameraManagerEngineFactory {
this._resolvedMediaCache = resolvedMediaCache;
}
public async createEngine(engine: Engine, eventCallback?: CameraEventCallback): Promise<CameraManagerEngine> {
public async createEngine(
engine: Engine,
eventCallback?: CameraEventCallback,
): Promise<CameraManagerEngine> {
let cameraManagerEngine: CameraManagerEngine;
switch (engine) {
case Engine.Generic:
+21 -12
View File
@@ -2,14 +2,15 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import uniq from 'lodash-es/uniq';
import { CameraConfig } from '../../config/types';
import { localize } from '../../localize/localize';
import { PTZCapabilities, PTZMovementType } from '../../types';
import { errorToConsole } from '../../utils/basic';
import { subscribeToTrigger } from '../../utils/ha';
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
import { Entity } from '../../utils/ha/entity-registry/types';
import { Camera } from '../camera';
import { Capabilities } from '../capabilities';
import { CameraInitializationError } from '../error';
import { PTZCapabilities, PTZMovementType } from '../types';
import { getCameraEntityFromConfig } from '../utils.js';
import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config';
import { getPTZInfo } from './requests';
import { PTZInfo, frigateEventChangeTriggerResponseSchema } from './types';
@@ -98,16 +99,24 @@ export class FrigateCamera extends Camera {
const config = this.getConfig();
const ptz = await this._getPTZCapabilities(hass, config);
const birdseye = isBirdseye(config);
this._capabilities = {
canFavoriteEvents: !birdseye,
canFavoriteRecordings: !birdseye,
canSeek: true,
supportsClips: !birdseye,
supportsSnapshots: !birdseye,
supportsRecordings: !birdseye,
supportsTimeline: !birdseye,
...(ptz && { ptz: ptz }),
};
this._capabilities = new Capabilities(
{
'favorite-events': !birdseye,
'favorite-recordings': false,
seek: !birdseye,
clips: !birdseye,
snapshots: !birdseye,
recordings: !birdseye,
live: true,
menu: true,
substream: true,
...(ptz && { ptz: ptz }),
},
{
disable: config.capabilities?.disable,
disableExcept: config.capabilities?.disable_except,
},
);
}
protected _getFrigateCameraNameFromEntity(entity: Entity): string | null {
+6 -6
View File
@@ -23,8 +23,8 @@ import { ViewMediaClassifier } from '../../view/media-classifier';
import { RecordingSegmentsCache, RequestCache } from '../cache';
import { Camera } from '../camera';
import {
CameraManagerEngine,
CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
CameraManagerEngine,
} from '../engine';
import { GenericCameraManagerEngine } from '../generic/engine-generic';
import { DateRange } from '../range';
@@ -59,18 +59,18 @@ import {
RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap,
} from '../types';
import { getDefaultGo2RTCEndpoint } from '../utils.js';
import { getDefaultGo2RTCEndpoint } from '../utils/go2rtc-endpoint';
import frigateLogo from './assets/frigate-logo-dark.svg';
import { FrigateCamera, isBirdseye } from './camera';
import { FrigateViewMediaFactory } from './media';
import { FrigateViewMediaClassifier } from './media-classifier';
import {
getEvents,
getEventSummary,
getRecordingSegments,
getRecordingsSummary,
NativeFrigateEventQuery,
NativeFrigateRecordingSegmentsQuery,
getEventSummary,
getEvents,
getRecordingSegments,
getRecordingsSummary,
retainEvent,
} from './requests';
import {
+20 -10
View File
@@ -7,6 +7,7 @@ import { getEntityIcon, getEntityTitle } from '../../utils/ha';
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
import { ViewMedia } from '../../view/media';
import { Camera } from '../camera';
import { Capabilities } from '../capabilities';
import { CameraManagerEngine } from '../engine';
import { CameraManagerReadOnlyConfigStore } from '../store';
import {
@@ -32,7 +33,8 @@ import {
RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap,
} from '../types';
import { getCameraEntityFromConfig, getDefaultGo2RTCEndpoint } from '../utils.js';
import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config';
import { getDefaultGo2RTCEndpoint } from '../utils/go2rtc-endpoint';
export class GenericCameraManagerEngine implements CameraManagerEngine {
protected _eventCallback?: CameraEventCallback;
@@ -51,15 +53,23 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
cameraConfig: CameraConfig,
): Promise<Camera> {
return await new Camera(cameraConfig, this, {
capabilities: {
canFavoriteEvents: false,
canFavoriteRecordings: false,
canSeek: false,
supportsClips: false,
supportsRecordings: false,
supportsSnapshots: false,
supportsTimeline: false,
},
capabilities: new Capabilities(
{
'favorite-events': false,
'favorite-recordings': false,
clips: false,
live: true,
menu: true,
recordings: false,
seek: false,
snapshots: false,
substream: true,
},
{
disable: cameraConfig.capabilities?.disable,
disableExcept: cameraConfig.capabilities?.disable_except,
},
),
eventCallback: this._eventCallback,
}).initialize(hass, entityRegistryManager);
}
+21 -29
View File
@@ -8,12 +8,14 @@ import { localize } from '../localize/localize.js';
import {
allPromises,
arrayify,
isTruthy,
recursivelyMergeObjectsNotArrays,
setify,
} from '../utils/basic.js';
import { getCameraID } from '../utils/camera.js';
import { log } from '../utils/debug.js';
import { ViewMedia } from '../view/media.js';
import { Capabilities } from './capabilities.js';
import { CameraManagerEngineFactory } from './engine-factory.js';
import { CameraManagerEngine } from './engine.js';
import { CameraInitializationError } from './error.js';
@@ -22,9 +24,7 @@ import {
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraManagerCameraCapabilities,
CameraManagerCameraMetadata,
CameraManagerCapabilities,
CameraManagerMediaCapabilities,
DataQuery,
Engine,
@@ -53,7 +53,7 @@ import {
RecordingSegmentsQueryResultsMap,
ResultsMap,
} from './types.js';
import { sortMedia } from './utils.js';
import { sortMedia } from './utils/sort-media.js';
export class QueryClassifier {
public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery {
@@ -260,10 +260,6 @@ export class CameraManager {
this._store.addCamera(camera);
});
if (!this._store.getVisibleCameraCount()) {
throw new CameraInitializationError(localize('error.no_visible_cameras'));
}
log(
this._api.getConfigManager().getCardWideConfig(),
'Frigate Card CameraManager initialized (Cameras: ',
@@ -739,33 +735,29 @@ export class CameraManager {
return engine.getCameraMetadata(hass, cameraConfig);
}
public getCameraCapabilities(
cameraID: string,
): CameraManagerCameraCapabilities | null {
public getCameraCapabilities(cameraID: string): Capabilities | null {
return this._store.getCamera(cameraID)?.getCapabilities() ?? null;
}
public getAggregateCameraCapabilities(
cameraIDs?: Set<string>,
): CameraManagerCapabilities | null {
const perCameraCapabilities = [...(cameraIDs ?? this._store.getCameraIDs())].map(
(cameraID) => this.getCameraCapabilities(cameraID),
);
public getAggregateCameraCapabilities(cameraIDs?: Set<string>): Capabilities {
const cameras = [...(cameraIDs ?? this._store.getCameraIDs())]
.map((cameraID) => this._store.getCamera(cameraID))
.filter(isTruthy);
return {
canFavoriteEvents: perCameraCapabilities.some((cap) => cap?.canFavoriteEvents),
canFavoriteRecordings: perCameraCapabilities.some(
(cap) => cap?.canFavoriteRecordings,
return new Capabilities({
live: cameras.some((camera) => camera.getCapabilities()?.has('live')),
clips: cameras.some((camera) => camera.getCapabilities()?.has('clips')),
recordings: cameras.some((camera) => camera.getCapabilities()?.has('recordings')),
snapshots: cameras.some((camera) => camera.getCapabilities()?.has('snapshots')),
'favorite-events': cameras.some((camera) =>
camera.getCapabilities()?.has('favorite-events'),
),
canSeek: perCameraCapabilities.some((cap) => cap?.canSeek),
supportsClips: perCameraCapabilities.some((cap) => cap?.supportsClips),
supportsRecordings: perCameraCapabilities.some((cap) => cap?.supportsRecordings),
supportsSnapshots: perCameraCapabilities.some((cap) => cap?.supportsSnapshots),
supportsTimeline: perCameraCapabilities.some((cap) => cap?.supportsTimeline),
supportsPTZ: perCameraCapabilities.some((cap) => !!cap?.ptz),
};
'favorite-recordings': cameras.some((camera) =>
camera.getCapabilities()?.has('favorite-recordings'),
),
seek: cameras.some((camera) => camera.getCapabilities()?.has('seek')),
menu: cameras.some((camera) => camera.getCapabilities()?.has('menu')),
});
}
public async executePTZAction(
+43 -32
View File
@@ -1,9 +1,10 @@
import { CameraConfig } from '../config/types';
import { CapabilityKey } from '../types';
import { allPromises } from '../utils/basic';
import { ViewMedia } from '../view/media';
import { Camera } from './camera';
import { CameraManagerEngine } from './engine';
import { Engine } from './types';
import { CapabilitySearchOptions, Engine } from './types';
type CameraManagerEngineCameraIDMap = Map<CameraManagerEngine, Set<string>>;
@@ -14,8 +15,8 @@ export interface CameraManagerReadOnlyConfigStore {
hasCameraID(cameraID: string): boolean;
getCamera(cameraID: string): Camera | null;
getCameras(): Map<string, Camera>;
getCameraCount(): number;
getVisibleCameraCount(): number;
getCameraConfigs(cameraIDs?: Iterable<string>): IterableIterator<CameraConfig>;
getCameraConfigEntries(
@@ -23,10 +24,15 @@ export interface CameraManagerReadOnlyConfigStore {
): IterableIterator<[string, CameraConfig]>;
getCameraIDs(): Set<string>;
getVisibleCameraIDs(): Set<string>;
getDefaultCameraID(): string | null;
getAllDependentCameras(cameraID: string): Set<string>;
getCameraIDsWithCapability(
capability: CapabilityKey | CapabilitySearchOptions,
): Set<string>;
getAllDependentCameras(
cameraID: string,
capability?: CapabilityKey | CapabilitySearchOptions,
): Set<string>;
}
export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
@@ -47,6 +53,9 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
public getCamera(cameraID: string): Camera | null {
return this._cameras.get(cameraID) ?? null;
}
public getCameras(): Map<string, Camera> {
return this._cameras;
}
public getCameraConfig(cameraID: string): CameraConfig | null {
return this._cameras.get(cameraID)?.getConfig() ?? null;
}
@@ -58,18 +67,11 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
public getCameraCount(): number {
return this._cameras.size;
}
public getVisibleCameraCount(): number {
return this.getVisibleCameraIDs().size;
}
public getDefaultCameraID(): string | null {
return this._cameras.keys().next().value ?? null;
}
public getCameras(): Map<string, Camera> {
return this._cameras;
}
public *getCameraConfigs(
cameraIDs?: Iterable<string>,
): IterableIterator<CameraConfig> {
@@ -93,8 +95,17 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
public getCameraIDs(): Set<string> {
return new Set(this._cameras.keys());
}
public getVisibleCameraIDs(): Set<string> {
return this._getMatchingCameraIDs((camera) => !camera.getConfig().hide);
public getCameraIDsWithCapability(
capability: CapabilityKey | CapabilitySearchOptions,
): Set<string> {
const output: Set<string> = new Set();
for (const camera of this._cameras.values()) {
if (camera.getCapabilities()?.matches(capability)) {
output.add(camera.getID());
}
}
return output;
}
public getCameraConfigForMedia(media: ViewMedia): CameraConfig | null {
@@ -138,35 +149,35 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore {
* @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();
public getAllDependentCameras(
cameraID: string,
capability?: CapabilitySearchOptions,
): Set<string> {
const visitedCameraIDs = new Set<string>();
const matchingCameraIDs: Set<string> = new Set();
const getDependentCameras = (cameraID: string): void => {
const cameraConfig = this.getCameraConfig(cameraID);
if (cameraConfig) {
cameraIDs.add(cameraID);
visitedCameraIDs.add(cameraID);
const camera = this.getCamera(cameraID);
const cameraConfig = camera?.getConfig();
if (camera && cameraConfig) {
if (!capability || camera.getCapabilities()?.matches(capability)) {
matchingCameraIDs.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);
for (const dependentCameraID of dependentCameras) {
if (!visitedCameraIDs.has(dependentCameraID)) {
getDependentCameras(dependentCameraID);
}
}
}
};
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;
return matchingCameraIDs;
}
}
+5 -25
View File
@@ -1,3 +1,4 @@
import { CapabilityKey } from '../types';
import { FrigateCardView } from '../config/types';
import { ViewMedia } from '../view/media';
@@ -91,32 +92,11 @@ 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;
canSeek: boolean;
supportsClips: boolean;
supportsRecordings: boolean;
supportsSnapshots: boolean;
supportsTimeline: boolean;
}
export interface CameraManagerCapabilities extends BaseCapabilities {
supportsPTZ: boolean;
}
export interface CameraManagerCameraCapabilities extends BaseCapabilities {
ptz?: PTZCapabilities;
interface CapabilitySearchAllAny {
allCapabilities?: CapabilityKey[];
anyCapabilities?: CapabilityKey[];
}
export type CapabilitySearchOptions = CapabilityKey | CapabilitySearchAllAny;
export interface CameraManagerMediaCapabilities {
canFavorite: boolean;
-84
View File
@@ -1,84 +0,0 @@
import endOfDay from 'date-fns/endOfDay';
import endOfHour from 'date-fns/endOfHour';
import endOfMinute from 'date-fns/endOfMinute';
import startOfDay from 'date-fns/startOfDay';
import startOfHour from 'date-fns/startOfHour';
import orderBy from 'lodash-es/orderBy';
import uniqBy from 'lodash-es/uniqBy';
import { CameraConfig } from '../config/types';
import { ViewMedia } from '../view/media';
import { DateRange } from './range';
import { CameraEndpoint } from './types';
export const convertRangeToCacheFriendlyTimes = (
range: DateRange,
options?: {
endCap?: boolean;
},
): DateRange => {
const widthSeconds = (range.end.getTime() - range.start.getTime()) / 1000;
let cacheableStart: Date;
let cacheableEnd: Date;
if (widthSeconds <= 60 * 60) {
cacheableStart = startOfHour(range.start);
cacheableEnd = endOfHour(range.end);
} else {
cacheableStart = startOfDay(range.start);
cacheableEnd = endOfDay(range.end);
}
if (options?.endCap) {
cacheableEnd = endOfMinute(capEndDate(cacheableEnd));
}
return {
start: cacheableStart,
end: cacheableEnd,
};
};
export const capEndDate = (end: Date): Date => {
const now = new Date();
return end > now ? now : end;
};
export const sortMedia = (mediaArray: ViewMedia[]): ViewMedia[] => {
return orderBy(
// Ensure uniqueness by the ID (if specified), otherwise all elements
// are assumed to be unique.
uniqBy(mediaArray, (media) => media.getID() ?? media),
// Sort all items leading oldest -> youngest (so media is loaded in this
// order in the viewer which matches the left-to-right timeline order).
(media) => media.getStartTime() ?? media.getID(),
'asc',
);
};
export const getCameraEntityFromConfig = (cameraConfig: CameraConfig): string | null => {
return cameraConfig.camera_entity ?? cameraConfig.webrtc_card?.entity ?? null;
};
export const getDefaultGo2RTCEndpoint = (
cameraConfig: CameraConfig,
options?: {
url?: string;
stream?: string;
},
): CameraEndpoint | null => {
const url = options?.url ?? cameraConfig.go2rtc?.url;
const stream = options?.stream ?? cameraConfig.go2rtc?.stream;
if (!url || !stream) {
return null;
}
const endpoint = `${url}/api/ws?src=${stream}`;
return {
endpoint: endpoint,
// Only sign the endpoint if it's local to HA.
sign: endpoint.startsWith('/'),
};
};
@@ -0,0 +1,5 @@
import { CameraConfig } from '../../config/types';
export const getCameraEntityFromConfig = (cameraConfig: CameraConfig): string | null => {
return cameraConfig.camera_entity ?? cameraConfig.webrtc_card?.entity ?? null;
};
+4
View File
@@ -0,0 +1,4 @@
export const capEndDate = (end: Date): Date => {
const now = new Date();
return end > now ? now : end;
};
@@ -0,0 +1,25 @@
import { CameraConfig } from '../../config/types';
import { CameraEndpoint } from '../types';
export const getDefaultGo2RTCEndpoint = (
cameraConfig: CameraConfig,
options?: {
url?: string;
stream?: string;
},
): CameraEndpoint | null => {
const url = options?.url ?? cameraConfig.go2rtc?.url;
const stream = options?.stream ?? cameraConfig.go2rtc?.stream;
if (!url || !stream) {
return null;
}
const endpoint = `${url}/api/ws?src=${stream}`;
return {
endpoint: endpoint,
// Only sign the endpoint if it's local to HA.
sign: endpoint.startsWith('/'),
};
};
@@ -0,0 +1,35 @@
import endOfDay from 'date-fns/endOfDay';
import endOfHour from 'date-fns/endOfHour';
import endOfMinute from 'date-fns/endOfMinute';
import startOfDay from 'date-fns/startOfDay';
import startOfHour from 'date-fns/startOfHour';
import { DateRange } from '../range';
import { capEndDate } from './cap-end-date';
export const convertRangeToCacheFriendlyTimes = (
range: DateRange,
options?: {
endCap?: boolean;
},
): DateRange => {
const widthSeconds = (range.end.getTime() - range.start.getTime()) / 1000;
let cacheableStart: Date;
let cacheableEnd: Date;
if (widthSeconds <= 60 * 60) {
cacheableStart = startOfHour(range.start);
cacheableEnd = endOfHour(range.end);
} else {
cacheableStart = startOfDay(range.start);
cacheableEnd = endOfDay(range.end);
}
if (options?.endCap) {
cacheableEnd = endOfMinute(capEndDate(cacheableEnd));
}
return {
start: cacheableStart,
end: cacheableEnd,
};
};
+16
View File
@@ -0,0 +1,16 @@
import orderBy from 'lodash-es/orderBy';
import uniqBy from 'lodash-es/uniqBy';
import { ViewMedia } from '../../view/media';
export const sortMedia = (mediaArray: ViewMedia[]): ViewMedia[] => {
return orderBy(
// Ensure uniqueness by the ID (if specified), otherwise all elements
// are assumed to be unique.
uniqBy(mediaArray, (media) => media.getID() ?? media),
// Sort all items leading oldest -> youngest (so media is loaded in this
// order in the viewer which matches the left-to-right timeline order).
(media) => media.getStartTime() ?? media.getID(),
'asc',
);
};
+91 -84
View File
@@ -1,3 +1,4 @@
import isEqual from 'lodash-es/isEqual';
import { ViewContext } from 'view';
import {
FRIGATE_CARD_VIEW_DEFAULT,
@@ -5,18 +6,21 @@ import {
FrigateCardView,
ViewDisplayMode,
} from '../config/types';
import { localize } from '../localize/localize';
import { log } from '../utils/debug';
import { executeMediaQueryForView } from '../utils/media-to-view';
import { View } from '../view/view';
import { getCameraIDsForViewName } from '../view/view-to-cameras';
import { CardViewAPI } from './types';
interface ViewManagerSetViewDefaultParameters {
cameraID?: string;
substream?: string;
// When failSafe is true, the view will be changed to an "always-works" view
// (e.g. `live`) if the proposed view is unsupported. By default the view will
// just not be changed.
// When failSafe is true, the view will be changed to the default view, or the
// `live` view if the default view is not supported, or failing that an error
// message is shown. Without `failSafe` the view will just not be changed if
// unsupported.
failSafe?: boolean;
}
@@ -49,9 +53,11 @@ export class ViewManager {
const config = this._api.getConfigManager().getConfig();
if (config) {
let forceCameraID: string | null = params?.cameraID ?? null;
const viewName = config.view.default;
if (!forceCameraID && this._view?.camera && config.view.update_cycle_camera) {
const cameraIDs = [
...this._api.getCameraManager().getStore().getVisibleCameraIDs(),
...getCameraIDsForViewName(this._api.getCameraManager(), viewName),
];
const currentIndex = cameraIDs.indexOf(this._view.camera);
const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1;
@@ -60,7 +66,7 @@ export class ViewManager {
this.setViewByParameters({
...params,
viewName: config.view.default,
viewName: viewName,
...(forceCameraID && { cameraID: forceCameraID }),
});
@@ -76,38 +82,82 @@ export class ViewManager {
if (config) {
let cameraID: string | null = null;
const cameras = this._api.getCameraManager().getStore().getVisibleCameraIDs();
if (cameras.size) {
if (params?.cameraID && cameras.has(params.cameraID)) {
cameraID = params.cameraID;
} else {
// Reset to the default camera.
cameraID = cameras.keys().next().value;
}
}
let viewName = params?.viewName ?? this._view?.view ?? config.view.default;
if (cameraID && viewName) {
if (!this.isViewSupportedByCamera(cameraID, viewName)) {
if (params.failSafe) {
const allCameraIDs = this._api.getCameraManager().getStore().getCameraIDs();
if (params?.cameraID && allCameraIDs.has(params.cameraID)) {
cameraID = params.cameraID;
} else {
const viewCameraIDs = getCameraIDsForViewName(
this._api.getCameraManager(),
viewName,
);
// Reset to the default camera.
cameraID = viewCameraIDs.keys().next().value;
}
if (!cameraID) {
if (params.failSafe) {
const camerasToCapabilities = [
...this._api.getCameraManager().getStore().getCameras(),
].reduce((acc, [cameraID, camera]) => {
const capabilities = camera.getCapabilities()?.getRawCapabilities();
if (capabilities) {
acc[cameraID] = capabilities;
}
return acc;
}, {});
this._api.getMessageManager().setMessageIfHigherPriority({
type: 'error',
message: localize('error.no_supported_cameras'),
context: {
view: viewName,
cameras_capabilities: camerasToCapabilities,
},
});
}
return;
}
if (!this.isViewSupportedByCamera(cameraID, viewName)) {
if (params.failSafe) {
if (this.isViewSupportedByCamera(cameraID, FRIGATE_CARD_VIEW_DEFAULT)) {
viewName = FRIGATE_CARD_VIEW_DEFAULT;
} else {
const capabilities = this._api
.getCameraManager()
.getStore()
.getCamera(cameraID)
?.getCapabilities()
?.getRawCapabilities();
this._api.getMessageManager().setMessageIfHigherPriority({
type: 'error',
message: localize('error.no_supported_camera'),
context: {
view: viewName,
camera: cameraID,
...(capabilities && { camera_capabilities: capabilities }),
},
});
return;
}
} else {
return;
}
const displayMode =
this._view?.displayMode ??
this._getDefaultDisplayModeForView(viewName, config);
let view: View = new View({
view: viewName,
camera: cameraID,
displayMode: displayMode,
});
if (params.substream) {
view = this._createViewWithSelectedSubstream(view, params.substream);
}
this._setView(view);
}
const displayMode =
this._view?.displayMode ?? this._getDefaultDisplayModeForView(viewName, config);
let view: View = new View({
view: viewName,
camera: cameraID,
displayMode: displayMode,
});
if (params.substream) {
view = this._createViewWithSelectedSubstream(view, params.substream);
}
this._setView(view);
}
}
@@ -121,6 +171,12 @@ export class ViewManager {
this._view = null;
}
protected _getCameraIDsInvolvedInView(view: View): Set<string> {
return view.supportsMultipleDisplayModes() && view.isGrid()
? getCameraIDsForViewName(this._api.getCameraManager(), view.view)
: getCameraIDsForViewName(this._api.getCameraManager(), view.view, view.camera);
}
public async setViewWithNewDisplayMode(displayMode: ViewDisplayMode): Promise<void> {
const hass = this._api.getHASSManager().getHASS();
@@ -129,18 +185,10 @@ export class ViewManager {
displayMode: displayMode,
});
const cameraCount = this._api
.getCameraManager()
.getStore()
.getVisibleCameraCount();
const queryCameraCount = view.query?.getQueryCameraIDs()?.size ?? 0;
const generateNewQuery =
view?.query &&
queryCameraCount &&
((view.isGrid() && queryCameraCount < cameraCount) ||
(!view.isGrid() && queryCameraCount > 1));
const expectedCameraIDs = this._getCameraIDsInvolvedInView(view);
const queryCameraIDs = view.query?.getQueryCameraIDs();
if (generateNewQuery && view && view.query) {
if (!isEqual(expectedCameraIDs, queryCameraIDs) && view && view.query) {
// If the user requests a grid but the current query does not have a
// query for more than one camera, reset the query results, change the
// existing query to refer to all cameras and execute it to fetch new
@@ -150,13 +198,7 @@ export class ViewManager {
viewWithNewQuery = await executeMediaQueryForView(
this._api.getCameraManager(),
view,
view.query
.clone()
.setQueryCameraIDs(
view.isGrid()
? this._api.getCameraManager().getStore().getVisibleCameraIDs()
: view.camera,
),
view.query.clone().setQueryCameraIDs(expectedCameraIDs),
);
} catch (e: unknown) {
this._api.getMessageManager().setErrorIfHigherPriority(e);
@@ -190,42 +232,7 @@ export class ViewManager {
}
public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean {
const dependentCamerasCapabilities = this._api
.getCameraManager()
.getAggregateCameraCapabilities(
this._api.getCameraManager().getStore().getAllDependentCameras(cameraID),
);
const allCamerasCapabilities = this._api
.getCameraManager()
.getAggregateCameraCapabilities(
this._api.getCameraManager().getStore().getCameraIDs(),
);
switch (view) {
case 'live':
case 'image':
case 'diagnostics':
return true;
case 'clip':
case 'clips':
return !!dependentCamerasCapabilities?.supportsClips;
case 'snapshot':
case 'snapshots':
return !!dependentCamerasCapabilities?.supportsSnapshots;
case 'recording':
case 'recordings':
return !!dependentCamerasCapabilities?.supportsRecordings;
case 'timeline':
// Show the timeline if any camera supports it, even cameras unrelated
// to the currently selected camera.
return !!allCamerasCapabilities?.supportsTimeline;
case 'media':
return (
!!dependentCamerasCapabilities?.supportsClips ||
!!dependentCamerasCapabilities?.supportsSnapshots ||
!!dependentCamerasCapabilities?.supportsRecordings
);
}
return !!getCameraIDsForViewName(this._api.getCameraManager(), view, cameraID).size;
}
protected _getDefaultDisplayModeForView(
+15 -10
View File
@@ -182,11 +182,11 @@ export class MediaFilterController {
return null;
};
const visibleCameraIDs = cameraManager.getStore().getVisibleCameraIDs();
if (!visibleCameraIDs.size || !values.mediaType) {
const cameraIDs =
getArrayValueAsSet(values.camera) ?? this._getAllCameraIDs(cameraManager);
if (!cameraIDs.size || !values.mediaType) {
return;
}
const cameraIDs = getArrayValueAsSet(values.camera) ?? visibleCameraIDs;
const when = this._getWhen(values.when);
const favorite = values.favorite
@@ -280,10 +280,16 @@ export class MediaFilterController {
this._host.requestUpdate();
}
protected _getAllCameraIDs(cameraManager: CameraManager): Set<string> {
return cameraManager.getStore().getCameraIDsWithCapability({
anyCapabilities: ['clips', 'snapshots', 'recordings'],
});
}
public computeInitialDefaultsFromView(cameraManager: CameraManager, view: View): void {
const queries = view.query?.getQueries();
const visibleCameraIDs = cameraManager.getStore().getVisibleCameraIDs();
if (!queries || !visibleCameraIDs.size) {
const allCameraIDs = this._getAllCameraIDs(cameraManager);
if (!queries || !allCameraIDs.size) {
return;
}
@@ -300,7 +306,7 @@ export class MediaFilterController {
);
// 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, visibleCameraIDs)) {
if (cameraIDSets.length === 1 && !isEqual(queries[0].cameraIDs, allCameraIDs)) {
cameraIDs = [...queries[0].cameraIDs];
}
@@ -375,8 +381,7 @@ export class MediaFilterController {
}
public computeCameraOptions(cameraManager: CameraManager): void {
const cameras = cameraManager.getStore().getVisibleCameraIDs();
this._cameraOptions = [...cameras].map((cameraID) => ({
this._cameraOptions = [...this._getAllCameraIDs(cameraManager)].map((cameraID) => ({
value: cameraID,
label: cameraManager.getCameraMetadata(cameraID)?.title ?? cameraID,
}));
@@ -451,9 +456,9 @@ export class MediaFilterController {
events: events,
recordings: recordings,
favorites: events
? !!managerCapabilities?.canFavoriteEvents
? managerCapabilities?.has('favorite-events')
: recordings
? !!managerCapabilities?.canFavoriteRecordings
? managerCapabilities?.has('favorite-recordings')
: false,
};
}
+22 -16
View File
@@ -24,6 +24,7 @@ import { getEntityIcon, getEntityTitle } from '../utils/ha';
import { hasUsablePTZ } from '../utils/ptz';
import { hasSubstream } from '../utils/substream';
import { View } from '../view/view';
import { getCameraIDsForViewName } from '../view/view-to-cameras';
export interface MenuButtonControllerOptions {
currentMediaLoadedInfo?: MediaLoadedInfo | null;
@@ -62,16 +63,12 @@ export class MenuButtonController {
view: View,
options?: MenuButtonControllerOptions,
): MenuItem[] {
const visibleCameraIDs = cameraManager.getStore().getVisibleCameraIDs();
const selectedCameraID = view.camera;
const substreamAwareCameraID =
view.context?.live?.overrides?.get(selectedCameraID) ?? selectedCameraID;
const selectedCameraConfig = cameraManager
.getStore()
.getCameraConfig(selectedCameraID);
const allSelectedCameraIDs = cameraManager
.getStore()
.getAllDependentCameras(selectedCameraID);
const substreamAwareCameraCapabilities =
cameraManager.getCameraCapabilities(substreamAwareCameraID);
@@ -98,9 +95,12 @@ export class MenuButtonController {
) as FrigateCardCustomAction,
});
if (visibleCameraIDs.size) {
// Show all cameras in the menu rather than just cameras that support the
// current view for a less surprising UX.
const menuCameraIDs = cameraManager.getStore().getCameraIDsWithCapability('menu');
if (menuCameraIDs.size) {
const menuItems = Array.from(
cameraManager.getStore().getCameraConfigEntries(visibleCameraIDs),
cameraManager.getStore().getCameraConfigEntries(menuCameraIDs),
([cameraID, config]) => {
const action = createFrigateCardCameraAction('camera_select', cameraID);
const metadata = cameraManager.getCameraMetadata(cameraID);
@@ -126,10 +126,15 @@ export class MenuButtonController {
});
}
if (selectedCameraID && allSelectedCameraIDs && view.is('live')) {
const dependencies = [...allSelectedCameraIDs];
const substreamCameraIDs = cameraManager
.getStore()
.getAllDependentCameras(selectedCameraID, 'substream');
if (dependencies.length === 2) {
if (selectedCameraID && substreamCameraIDs && view.is('live')) {
const substreams = [...substreamCameraIDs].filter((cameraID) => cameraID !== selectedCameraID);
const streams = [selectedCameraID, ...substreams];
if (streams.length === 2) {
// If there are only two dependencies (the main camera, and 1 other)
// then use a button not a menu to toggle.
buttons.push({
@@ -145,14 +150,14 @@ export class MenuButtonController {
hasSubstream(view) ? 'live_substream_off' : 'live_substream_on',
) as FrigateCardCustomAction,
});
} else if (dependencies.length > 2) {
const menuItems = Array.from(dependencies, (cameraID) => {
} else if (streams.length > 2) {
const menuItems = Array.from(streams, (streamID) => {
const action = createFrigateCardCameraAction(
'live_substream_select',
cameraID,
streamID,
);
const metadata = cameraManager.getCameraMetadata(cameraID) ?? undefined;
const cameraConfig = cameraManager.getStore().getCameraConfig(cameraID);
const metadata = cameraManager.getCameraMetadata(streamID) ?? undefined;
const cameraConfig = cameraManager.getStore().getCameraConfig(streamID);
return {
enabled: true,
icon: metadata?.icon,
@@ -161,7 +166,7 @@ export class MenuButtonController {
title: metadata?.title,
selected:
(view.context?.live?.overrides?.get(selectedCameraID) ??
selectedCameraID) === cameraID,
selectedCameraID) === streamID,
...(action && { tap_action: action }),
};
});
@@ -409,7 +414,8 @@ export class MenuButtonController {
});
}
if (view.supportsMultipleDisplayModes() && visibleCameraIDs.size > 1) {
const viewCameraIDs = getCameraIDsForViewName(cameraManager, view.view);
if (view.supportsMultipleDisplayModes() && viewCameraIDs.size > 1) {
const isGrid = view.isGrid();
buttons.push({
icon: isGrid ? 'mdi:grid-off' : 'mdi:grid',
+1 -1
View File
@@ -137,7 +137,7 @@ export class PTZController {
const ptzCapabilities = this._cameraManager.getCameraCapabilities(
this._cameraID,
)?.ptz;
)?.getPTZCapabilities();
const defaultActions: FrigateCardPTZActions = {};
const panTilt = ptzCapabilities?.panTilt;
+4 -3
View File
@@ -4,14 +4,15 @@ import { DataSet } from 'vis-data';
import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext';
import { CameraManager } from '../camera-manager/manager';
import {
compressRanges,
ExpiringMemoryRangeSet,
MemoryRangeSet,
compressRanges,
} from '../camera-manager/range';
import { EventQuery, RecordingQuery, RecordingSegment } from '../camera-manager/types';
import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/utils';
import { capEndDate } from '../camera-manager/utils/cap-end-date';
import { convertRangeToCacheFriendlyTimes } from '../camera-manager/utils/range-to-cache-friendly';
import { ClipsOrSnapshotsOrAll } from '../types';
import { ModifyInterface, errorToConsole } from '../utils/basic.js';
import { errorToConsole, ModifyInterface } from '../utils/basic.js';
import { ViewMedia } from '../view/media';
// Allow timeline freshness to be at least this number of seconds out of date
+9 -9
View File
@@ -324,7 +324,7 @@ export class FrigateCardLiveGrid extends LitElement {
}
protected _needsGrid(): boolean {
const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
return (
!!this.view?.isGrid() &&
!!this.view?.supportsMultipleDisplayModes() &&
@@ -343,8 +343,8 @@ export class FrigateCardLiveGrid extends LitElement {
if (!this.conditionsManagerEpoch || !this.nonOverriddenLiveConfig) {
return;
}
const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
if (!cameraIDs || !this._needsGrid()) {
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
if (!cameraIDs?.size || !this._needsGrid()) {
return this._renderCarousel();
}
return html`
@@ -416,8 +416,8 @@ export class FrigateCardLiveCarousel extends LitElement {
}
protected _getSelectedCameraIndex(): number {
const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
if (!cameraIDs || !this.view || this.viewFilterCameraID) {
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
if (!cameraIDs?.size || !this.view || this.viewFilterCameraID) {
// If the carousel is limited to a single cameraID, the first (only)
// element is always the selected one.
return 0;
@@ -481,7 +481,7 @@ export class FrigateCardLiveCarousel extends LitElement {
const cameraIDs = this.viewFilterCameraID
? new Set([this.viewFilterCameraID])
: this.cameraManager.getStore().getVisibleCameraIDs();
: this.cameraManager?.getStore().getCameraIDsWithCapability('live');
const slides: TemplateResult[] = [];
const cameraToSlide: Record<string, number> = {};
@@ -508,8 +508,8 @@ export class FrigateCardLiveCarousel extends LitElement {
}
protected _setViewHandler(ev: CustomEvent<CarouselSelected>): void {
const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
if (cameraIDs && ev.detail.index !== this._getSelectedCameraIndex()) {
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
if (cameraIDs?.size && ev.detail.index !== this._getSelectedCameraIndex()) {
this._setViewCameraID([...cameraIDs][ev.detail.index]);
}
}
@@ -597,7 +597,7 @@ export class FrigateCardLiveCarousel extends LitElement {
protected _getCameraIDsOfNeighbors(): [string | null, string | null] {
const cameraIDs = this.cameraManager
? [...this.cameraManager.getStore().getVisibleCameraIDs()]
? [...this.cameraManager?.getStore().getCameraIDsWithCapability('live')]
: [];
if (this.viewFilterCameraID || cameraIDs.length <= 1 || !this.view || !this.hass) {
return [null, null];
+14 -6
View File
@@ -159,14 +159,22 @@ export class FrigateCardSurround extends LitElement {
}
protected _getCameraIDsForTimeline(): Set<string> | null {
if (!this.view) {
if (!this.view || !this.cameraManager) {
return null;
}
if (this.view?.is('live')) {
return this.view.isGrid()
? this.cameraManager?.getStore().getVisibleCameraIDs() ?? null
: this.cameraManager?.getStore().getAllDependentCameras(this.view.camera) ??
null;
if (this.view.is('live')) {
const capabilitySearch = {
anyCapabilities: ['clips' as const, 'snapshots' as const, 'recordings' as const],
};
if (this.view.supportsMultipleDisplayModes() && this.view.isGrid()) {
return this.cameraManager
.getStore()
.getCameraIDsWithCapability(capabilitySearch);
} else {
return this.cameraManager
.getStore()
.getAllDependentCameras(this.view.camera, capabilitySearch);
}
}
if (this.view.isViewerView()) {
return this.view.query?.getQueryCameraIDs() ?? null;
+43 -71
View File
@@ -27,7 +27,7 @@ import {
import { CameraManager } from '../camera-manager/manager';
import { rangesOverlap } from '../camera-manager/range';
import { MediaQuery } from '../camera-manager/types';
import { convertRangeToCacheFriendlyTimes } from '../camera-manager/utils';
import { convertRangeToCacheFriendlyTimes } from '../camera-manager/utils/range-to-cache-friendly';
import {
FrigateCardTimelineItem,
TimelineDataSource,
@@ -72,7 +72,6 @@ import { MediaQueriesResults } from '../view/media-queries-results';
import { View } from '../view/view';
import './date-picker.js';
import { DatePickerEvent, FrigateCardDatePicker } from './date-picker.js';
import { dispatchMessageEvent } from './message.js';
import './thumbnail.js';
interface FrigateCardGroupData {
@@ -275,18 +274,11 @@ export class FrigateCardTimelineCore extends LitElement {
request.detail.view = this.view;
}
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
const cameraIDs = this._getTimelineCameraIDs();
if (!this.hass || !this.view || !this.timelineConfig || !cameraIDs) {
if (!this.hass || !this.view || !this.timelineConfig || !this.cameraIDs?.size) {
return;
}
const capabilities = this.cameraManager?.getAggregateCameraCapabilities(cameraIDs);
const panMode = this._getEffectivePanMode();
const panTitle =
@@ -306,55 +298,43 @@ export class FrigateCardTimelineCore extends LitElement {
? 'mdi:play-box-lock'
: 'mdi:camera-lock';
return html` ${capabilities?.supportsTimeline
? html` <div
@frigate-card:timeline:thumbnail-data-request=${this._handleThumbnailDataRequest.bind(
this,
)}
class="timeline"
${ref(this._refTimeline)}
>
<div class="timeline-tools">
${this._shouldSupportSeeking()
? html` <ha-icon
.icon=${panIcon}
@click=${() => {
this._panMode =
panMode === 'pan'
? 'seek'
: panMode === 'seek'
? 'seek-in-media'
: panMode === 'seek-in-media'
? 'seek-in-camera'
: 'pan';
}}
aria-label="${panTitle}"
title="${panTitle}"
>
</ha-icon>`
: ''}
<frigate-card-date-picker
${ref(this._refDatePicker)}
@frigate-card:date-picker:change=${(ev: CustomEvent<DatePickerEvent>) => {
if (ev.detail.date) {
this._timeline?.moveTo(ev.detail.date);
}
return html` <div
@frigate-card:timeline:thumbnail-data-request=${this._handleThumbnailDataRequest.bind(
this,
)}
class="timeline"
${ref(this._refTimeline)}
>
<div class="timeline-tools">
${this._shouldSupportSeeking()
? html` <ha-icon
.icon=${panIcon}
@click=${() => {
this._panMode =
panMode === 'pan'
? 'seek'
: panMode === 'seek'
? 'seek-in-media'
: panMode === 'seek-in-media'
? 'seek-in-camera'
: 'pan';
}}
aria-label="${panTitle}"
title="${panTitle}"
>
</frigate-card-date-picker>
</div>
</div>`
: ''}`;
}
/**
* Get all the keys of the cameras in scope for this timeline.
* @returns A set of camera ids (may be empty).
*/
protected _getTimelineCameraIDs(): Set<string> | null {
return (
this.cameraIDs ?? this.cameraManager?.getStore().getVisibleCameraIDs() ?? null
);
</ha-icon>`
: ''}
<frigate-card-date-picker
${ref(this._refDatePicker)}
@frigate-card:date-picker:change=${(ev: CustomEvent<DatePickerEvent>) => {
if (ev.detail.date) {
this._timeline?.moveTo(ev.detail.date);
}
}}
>
</frigate-card-date-picker>
</div>
</div>`;
}
/**
@@ -555,7 +535,6 @@ export class FrigateCardTimelineCore extends LitElement {
stopEventFromActivatingCardWideActions(properties.event);
}
const timelineCameraIDs = this._getTimelineCameraIDs();
if (
this._ignoreClick ||
!this.hass ||
@@ -563,7 +542,8 @@ export class FrigateCardTimelineCore extends LitElement {
!this.view ||
!this.cameraManager ||
!this.cardWideConfig ||
!timelineCameraIDs ||
!this.cameraIDs ||
!this.cameraIDs.size ||
!this._timelineSource ||
!properties.what
) {
@@ -767,14 +747,13 @@ export class FrigateCardTimelineCore extends LitElement {
*/
protected _getGroups(): DataGroupCollectionType {
const groups: FrigateCardGroupData[] = [];
(this._getTimelineCameraIDs() ?? []).forEach((cameraID) => {
(this.cameraIDs ?? []).forEach((cameraID: string) => {
if (!this.hass || !this.cameraManager) {
return;
}
const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID);
const cameraCapabilities = this.cameraManager.getCameraCapabilities(cameraID);
if (cameraMetadata && cameraCapabilities?.supportsTimeline) {
if (cameraMetadata) {
groups.push({
id: cameraID,
content: cameraMetadata.title,
@@ -1142,11 +1121,10 @@ export class FrigateCardTimelineCore extends LitElement {
changedProps.has('timelineConfig') ||
changedProps.has('cameraIDs')
) {
const cameraIDs = this._getTimelineCameraIDs();
if (cameraIDs && this.cameraManager && this.timelineConfig) {
if (this.cameraIDs?.size && this.cameraManager && this.timelineConfig) {
this._timelineSource = new TimelineDataSource(
this.cameraManager,
cameraIDs,
this.cameraIDs,
this.timelineConfig.events_media_type,
this.timelineConfig.show_recordings,
);
@@ -1191,12 +1169,6 @@ export class FrigateCardTimelineCore extends LitElement {
const groups = this._getGroups();
if (!groups.length) {
if (!this.mini) {
// Don't show an empty timeline, show a message instead.
dispatchMessageEvent(this, localize('error.timeline_no_cameras'), 'info', {
icon: 'mdi:chart-gantt',
});
}
return;
}
+3
View File
@@ -37,6 +37,9 @@ export class FrigateCardTimeline extends LitElement {
.timelineConfig=${this.timelineConfig}
.thumbnailConfig=${this.timelineConfig.controls.thumbnails}
.cameraManager=${this.cameraManager}
.cameraIDs=${this.cameraManager?.getStore().getCameraIDsWithCapability({
anyCapabilities: ['clips', 'snapshots', 'recordings'],
})}
.cardWideConfig=${this.cardWideConfig}
.itemClickAction=${this.timelineConfig.controls.thumbnails.mode === 'none'
? 'play'
+6
View File
@@ -678,4 +678,10 @@ const UPGRADES = [
typeof data === 'object' && data ? data[CONF_AUTOMATIONS] : {},
);
},
upgradeArrayOfObjects(
CONF_CAMERAS,
upgradeMoveToWithOverrides('hide', 'capabilities', {
transform: (val) => (val === true ? { disable_except: 'substream' } : null),
}),
),
];
+11 -12
View File
@@ -9,6 +9,7 @@ import {
} from '@dermotduffy/custom-card-helpers';
import { z } from 'zod';
import { MEDIA_CHUNK_SIZE_DEFAULT, MEDIA_CHUNK_SIZE_MAX } from '../const.js';
import { capabilityKeys } from '../types.js';
import { deepRemoveDefaults } from '../utils/zod.js';
// *************************************************************************
@@ -44,6 +45,8 @@ const FRIGATE_CARD_VIEWS = [
] as const;
export type FrigateCardView = (typeof FRIGATE_CARD_VIEWS)[number];
// The default view (may not be supported on all cameras).
export const FRIGATE_CARD_VIEW_DEFAULT = 'live' as const;
export const MEDIA_ACTION_NEGATIVE_CONDITIONS = ['unselected', 'hidden'] as const;
@@ -730,12 +733,7 @@ const timelineCoreConfigDefault = {
pan_mode: 'pan' as const,
};
export const timelinePanModeSchema = z.enum([
'pan',
'seek',
'seek-in-media',
'seek-in-camera',
]);
const timelinePanModeSchema = z.enum(['pan', 'seek', 'seek-in-media', 'seek-in-camera']);
export type TimelinePanMode = z.infer<typeof timelinePanModeSchema>;
const timelineCoreConfigSchema = z.object({
@@ -757,9 +755,7 @@ const timelineCoreConfigSchema = z.object({
.optional()
.default(timelineCoreConfigDefault.show_recordings),
style: z.enum(['stack', 'ribbon']).optional().default(timelineCoreConfigDefault.style),
pan_mode: timelinePanModeSchema
.optional()
.default(timelineCoreConfigDefault.pan_mode),
pan_mode: timelinePanModeSchema.optional().default(timelineCoreConfigDefault.pan_mode),
});
export type TimelineCoreConfig = z.infer<typeof timelineCoreConfigSchema>;
@@ -1119,7 +1115,6 @@ const cameraConfigDefault = {
frigate: {
client_id: 'frigate' as const,
},
hide: false,
image: {
refresh_seconds: 1,
},
@@ -1151,8 +1146,12 @@ export const cameraConfigSchema = z
icon: z.string().optional(),
title: z.string().optional(),
// Used to hide the camera (e.g. when used only as a dependency).
hide: z.boolean().optional(),
capabilities: z
.object({
disable: z.enum(capabilityKeys).array().optional(),
disable_except: z.enum(capabilityKeys).array().optional(),
})
.optional(),
// Optional identifier to separate different camera configurations used in
// this card.
+4 -1
View File
@@ -8,6 +8,10 @@ export const CONF_CAMERAS_ARRAY_CAMERA_ENTITY =
`${CONF_CAMERAS}.#.camera_entity` as const;
export const CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME =
`${CONF_CAMERAS}.#.frigate.camera_name` as const;
export const CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE =
`${CONF_CAMERAS}.#.capabilities.disable` as const;
export const CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT =
`${CONF_CAMERAS}.#.capabilities.disable_except` as const;
export const CONF_CAMERAS_ARRAY_CAST_METHOD = `${CONF_CAMERAS}.#.cast.method` as const;
export const CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH =
`${CONF_CAMERAS}.#.cast.dashboard.dashboard_path` as const;
@@ -25,7 +29,6 @@ export const CONF_CAMERAS_ARRAY_FRIGATE_ZONES =
export const CONF_CAMERAS_ARRAY_GO2RTC_MODES = `${CONF_CAMERAS}.#.go2rtc.modes` as const;
export const CONF_CAMERAS_ARRAY_GO2RTC_STREAM =
`${CONF_CAMERAS}.#.go2rtc.stream` as const;
export const CONF_CAMERAS_ARRAY_HIDE = `${CONF_CAMERAS}.#.hide` as const;
export const CONF_CAMERAS_ARRAY_ICON = `${CONF_CAMERAS}.#.icon` as const;
export const CONF_CAMERAS_ARRAY_ID = `${CONF_CAMERAS}.#.id` as const;
export const CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS =
+81 -11
View File
@@ -31,8 +31,9 @@ import {
THUMBNAIL_WIDTH_MIN,
} from './config/types.js';
import {
CONF_CAMERAS,
CONF_CAMERAS_ARRAY_CAMERA_ENTITY,
CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT,
CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE,
CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH,
CONF_CAMERAS_ARRAY_CAST_DASHBOARD_VIEW_PATH,
CONF_CAMERAS_ARRAY_CAST_METHOD,
@@ -49,7 +50,6 @@ import {
CONF_CAMERAS_ARRAY_FRIGATE_ZONES,
CONF_CAMERAS_ARRAY_GO2RTC_MODES,
CONF_CAMERAS_ARRAY_GO2RTC_STREAM,
CONF_CAMERAS_ARRAY_HIDE,
CONF_CAMERAS_ARRAY_ICON,
CONF_CAMERAS_ARRAY_ID,
CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS,
@@ -67,8 +67,9 @@ import {
CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY,
CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY,
CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL,
CONF_DIMENSIONS_ASPECT_RATIO,
CONF_CAMERAS,
CONF_DIMENSIONS_ASPECT_RATIO_MODE,
CONF_DIMENSIONS_ASPECT_RATIO,
CONF_DIMENSIONS_MAX_HEIGHT,
CONF_DIMENSIONS_MIN_HEIGHT,
CONF_IMAGE_MODE,
@@ -91,7 +92,6 @@ import {
CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE,
CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA_TYPE,
CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
CONF_LIVE_CONTROLS_TIMELINE_PAN_MODE,
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
@@ -100,6 +100,7 @@ import {
CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
CONF_LIVE_CONTROLS_TIMELINE_EVENTS_MEDIA_TYPE,
CONF_LIVE_CONTROLS_TIMELINE_MODE,
CONF_LIVE_CONTROLS_TIMELINE_PAN_MODE,
CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS,
CONF_LIVE_CONTROLS_TIMELINE_STYLE,
CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS,
@@ -157,8 +158,8 @@ import {
CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
CONF_MEDIA_VIEWER_ZOOMABLE,
CONF_MENU_ALIGNMENT,
CONF_MENU_BUTTONS,
CONF_MENU_BUTTON_SIZE,
CONF_MENU_BUTTONS,
CONF_MENU_POSITION,
CONF_MENU_STYLE,
CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR,
@@ -182,14 +183,14 @@ import {
CONF_VIEW_DEFAULT,
CONF_VIEW_INTERACTION_SECONDS,
CONF_VIEW_RESET_AFTER_INTERACTION,
CONF_VIEW_TRIGGERS,
CONF_VIEW_TRIGGERS_ACTIONS,
CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE,
CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
CONF_VIEW_TRIGGERS_ACTIONS,
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS,
CONF_VIEW_TRIGGERS_UNTRIGGER_SECONDS,
CONF_VIEW_TRIGGERS,
CONF_VIEW_UPDATE_CYCLE_CAMERA,
CONF_VIEW_UPDATE_FORCE,
CONF_VIEW_UPDATE_SECONDS,
@@ -208,6 +209,7 @@ import {
const MENU_BUTTONS = 'buttons';
const MENU_CAMERAS = 'cameras';
const MENU_CAMERAS_CAPABILITIES = 'cameras.capabilities';
const MENU_CAMERAS_CAST = 'cameras.cast';
const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies';
const MENU_CAMERAS_DIMENSIONS = 'cameras.dimensions';
@@ -741,6 +743,50 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
},
];
protected _capabilities: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'live',
label: localize('config.cameras.capabilities.capabilities.live'),
},
{
value: 'substream',
label: localize('config.cameras.capabilities.capabilities.substream'),
},
{
value: 'clips',
label: localize('config.cameras.capabilities.capabilities.clips'),
},
{
value: 'recordings',
label: localize('config.cameras.capabilities.capabilities.recordings'),
},
{
value: 'snapshots',
label: localize('config.cameras.capabilities.capabilities.snapshots'),
},
{
value: 'favorite-events',
label: localize('config.cameras.capabilities.capabilities.favorite-events'),
},
{
value: 'favorite-recordings',
label: localize('config.cameras.capabilities.capabilities.favorite-recordings'),
},
{
value: 'seek',
label: localize('config.cameras.capabilities.capabilities.seek'),
},
{
value: 'ptz',
label: localize('config.cameras.capabilities.capabilities.ptz'),
},
{
value: 'menu',
label: localize('config.cameras.capabilities.capabilities.menu'),
},
];
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
@@ -1688,10 +1734,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${this._renderStringInput(
getArrayConfigPath(CONF_CAMERAS_ARRAY_ID, cameraIndex),
)}
${this._renderSwitch(
getArrayConfigPath(CONF_CAMERAS_ARRAY_HIDE, cameraIndex),
this._defaults.cameras.hide,
)}
${this._putInSubmenu(
MENU_CAMERAS_ENGINE,
true,
@@ -1933,6 +1975,34 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
)}
`,
)}
${this._putInSubmenu(
MENU_CAMERAS_CAPABILITIES,
cameraIndex,
'config.cameras.capabilities.editor_label',
{ name: 'mdi:cog-stop' },
html`
${this._renderOptionSelector(
getArrayConfigPath(
CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE,
cameraIndex,
),
this._capabilities,
{
multiple: true,
},
)}
${this._renderOptionSelector(
getArrayConfigPath(
CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT,
cameraIndex,
),
this._capabilities,
{
multiple: true,
},
)}
`,
)}
</div>`
: ``}
</div>
+21 -4
View File
@@ -10,6 +10,23 @@
"config": {
"cameras": {
"camera_entity": "Camera Entity",
"capabilities": {
"disable": "Disable",
"disable_except": "Disable except",
"editor_label": "Camera capabilities",
"capabilities": {
"clips": "Clips",
"favorite-events": "Favorite Events",
"favorite-recordings": "Favorite Recordings",
"live": "Live",
"menu": "Menu",
"ptz": "PTZ",
"recordings": "Recordings",
"seek": "Seeking",
"snapshots": "Snapshots",
"substream": "Substream"
}
},
"cast": {
"dashboard": {
"dashboard_path": "Dashboard path",
@@ -50,7 +67,7 @@
"frigate": {
"camera_name": "Frigate camera name (Autodetected from entity)",
"client_id": "Frigate client id (For >1 Frigate server)",
"editor_label": "Frigate Options",
"editor_label": "Frigate options",
"labels": "Frigate labels/object filters",
"url": "Frigate server URL",
"zones": "Frigate zones"
@@ -87,7 +104,7 @@
"webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)"
},
"motioneye": {
"editor_label": "MotionEye Options",
"editor_label": "MotionEye options",
"images": {
"directory_pattern": "Images directory pattern",
"file_pattern": "Images file pattern"
@@ -527,9 +544,9 @@
"no_camera_name": "Could not determine a Frigate camera name for camera (or one of its dependents), please specify either 'camera_entity' or 'camera_name'",
"no_dashboard_or_view": "Both 'dashboard_path' and 'view_path' parameters are required for the 'dashboard' cast method",
"no_live_camera": "The camera_entity parameter must be set and valid for this live provider",
"no_visible_cameras": "No visible cameras found, you must configure at least one non-hidden camera",
"no_supported_camera": "The selected camera does not support this view",
"no_supported_cameras": "No cameras support this view",
"reconnecting": "Reconnecting",
"timeline_no_cameras": "No Frigate cameras to show in timeline",
"too_many_automations": "Too many nested automation calls, please check your configuration for loops",
"troubleshooting": "Check troubleshooting",
"unknown": "Unknown error",
+19 -1
View File
@@ -10,6 +10,23 @@
"config": {
"cameras": {
"camera_entity": "Entità della telecamera",
"capabilities": {
"disable": "",
"disable_except": "",
"editor_label": "",
"capabilities": {
"clips": "",
"favorite-events": "",
"favorite-recordings": "",
"live": "",
"menu": "",
"ptz": "",
"recordings": "",
"seek": "",
"snapshots": "",
"substream": ""
}
},
"cast": {
"dashboard": {
"dashboard_path": "",
@@ -518,7 +535,8 @@
"no_camera_name": "Impossibile determinare un nome della telecamera in Frigate, si prega di specificare 'camera_enty' o 'camera_name'",
"no_dashboard_or_view": "",
"no_live_camera": "Il parametro fotocamera_enty deve essere impostato e valido per questo provider live",
"no_visible_cameras": "Nessuna telecamera visibile trovata, è necessario configurare almeno una telecamera non nascosta",
"no_supported_camera": "",
"no_supported_cameras": "",
"reconnecting": "Riconnessione",
"timeline_no_cameras": "Nessuna telecamera damostrare in Frigate nella timeline",
"too_many_automations": "",
+19 -1
View File
@@ -10,6 +10,23 @@
"config": {
"cameras": {
"camera_entity": "Entidade da Câmera",
"capabilities": {
"disable": "",
"disable_except": "",
"editor_label": "",
"capabilities": {
"clips": "",
"favorite-events": "",
"favorite-recordings": "",
"live": "",
"menu": "",
"ptz": "",
"recordings": "",
"seek": "",
"snapshots": "",
"substream": ""
}
},
"cast": {
"dashboard": {
"dashboard_path": "",
@@ -525,7 +542,8 @@
"no_camera_name": "Não foi possível determinar o nome da câmera da Frigate, especifique 'camera_entity' ou 'camera_name' para a câmera a seguir",
"no_dashboard_or_view": "",
"no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este provedor ativo",
"no_visible_cameras": "Nenhuma câmera visível encontrada, você deve configurar pelo menos uma câmera não oculta",
"no_supported_camera": "",
"no_supported_cameras": "",
"reconnecting": "Reconectando",
"timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo",
"too_many_automations": "",
+19 -1
View File
@@ -10,6 +10,23 @@
"config": {
"cameras": {
"camera_entity": "Entidade da Câmera",
"capabilities": {
"disable": "",
"disable_except": "",
"editor_label": "",
"capabilities": {
"clips": "",
"favorite-events": "",
"favorite-recordings": "",
"live": "",
"menu": "",
"ptz": "",
"recordings": "",
"seek": "",
"snapshots": "",
"substream": ""
}
},
"cast": {
"dashboard": {
"dashboard_path": "",
@@ -511,7 +528,8 @@
"no_camera_name": "Não foi possível determinar o nome da câmera da Frigate, especifique 'camera_entity' ou 'camera_name' para a câmera a seguir",
"no_dashboard_or_view": "",
"no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este serviço ativo",
"no_visible_cameras": "Sem camaras visiveis",
"no_supported_camera": "",
"no_supported_cameras": "",
"reconnecting": "A voltar a ligar",
"timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo",
"too_many_automations": "",
+45 -1
View File
@@ -1,4 +1,8 @@
import { HomeAssistant, LovelaceCardConfig, Themes } from '@dermotduffy/custom-card-helpers';
import {
HomeAssistant,
LovelaceCardConfig,
Themes,
} from '@dermotduffy/custom-card-helpers';
import { StyleInfo } from 'lit/directives/style-map.js';
import { z } from 'zod';
@@ -87,6 +91,46 @@ export interface CardHelpers {
}>;
}
export type PTZMovementType = 'relative' | 'continuous';
export interface PTZCapabilities {
panTilt?: PTZMovementType[];
zoom?: PTZMovementType[];
presets?: string[];
}
export interface CapabilitiesRaw {
live?: boolean;
substream?: boolean;
clips?: boolean;
recordings?: boolean;
snapshots?: boolean;
'favorite-events'?: boolean;
'favorite-recordings'?: boolean;
seek?: boolean;
ptz?: PTZCapabilities;
menu?: boolean;
}
export type CapabilityKey = keyof CapabilitiesRaw;
export const capabilityKeys: readonly [CapabilityKey, ...CapabilityKey[]] = [
'clips',
'favorite-events',
'favorite-recordings',
'live',
'menu',
'ptz',
'recordings',
'seek',
'snapshots',
'substream',
] as const;
// *************************************************************************
// Home Assistant API types.
// *************************************************************************
+12 -5
View File
@@ -1,6 +1,6 @@
import { ViewContext } from 'view';
import { CameraManager } from '../camera-manager/manager';
import { MediaQuery } from '../camera-manager/types';
import { CapabilitySearchOptions, MediaQuery } from '../camera-manager/types';
import { dispatchFrigateCardErrorEvent } from '../components/message';
import { CardWideConfig, FrigateCardView } from '../config/types';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const';
@@ -30,9 +30,16 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
useCache?: boolean;
},
): Promise<void> => {
const capabilitySearch: CapabilitySearchOptions =
!options?.eventsMediaType || options?.eventsMediaType === 'all'
? {
anyCapabilities: ['clips', 'snapshots'],
}
: options.eventsMediaType;
const cameraIDs = options?.allCameras
? cameraManager.getStore().getVisibleCameraIDs()
: cameraManager.getStore().getAllDependentCameras(view.camera);
? cameraManager.getStore().getCameraIDsWithCapability(capabilitySearch)
: cameraManager.getStore().getAllDependentCameras(view.camera, capabilitySearch);
if (!cameraIDs.size) {
return;
}
@@ -99,8 +106,8 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
},
): Promise<void> => {
const cameraIDs = options?.allCameras
? cameraManager.getStore().getVisibleCameraIDs()
: cameraManager.getStore().getAllDependentCameras(view.camera);
? cameraManager.getStore().getCameraIDsWithCapability('recordings')
: cameraManager.getStore().getAllDependentCameras(view.camera, 'recordings');
if (!cameraIDs.size) {
return;
}
+8 -3
View File
@@ -1,8 +1,8 @@
import { CameraManagerCameraCapabilities } from '../camera-manager/types';
import { Capabilities } from '../camera-manager/capabilities';
import { FrigateCardPTZConfig, PTZ_CONTROL_ACTIONS } from '../config/types';
export const hasUsablePTZ = (
capabilities: CameraManagerCameraCapabilities | null,
capabilities: Capabilities | null,
config: FrigateCardPTZConfig,
): boolean => {
for (const actionName of PTZ_CONTROL_ACTIONS) {
@@ -10,5 +10,10 @@ export const hasUsablePTZ = (
return true;
}
}
return !!capabilities?.ptz;
const ptzCapabilities = capabilities?.getPTZCapabilities();
return (
!!ptzCapabilities?.panTilt?.length ||
!!ptzCapabilities?.zoom?.length ||
!!ptzCapabilities?.presets?.length
);
};
+51
View File
@@ -0,0 +1,51 @@
import { CameraManager } from '../camera-manager/manager';
import { FrigateCardView } from '../config/types';
/**
* Get cameraIDs that are relevant for a given view name based on camera
* capability (if camera specified).
*/
export const getCameraIDsForViewName = (
cameraManager: CameraManager,
viewName: FrigateCardView,
cameraID?: string,
): Set<string> => {
switch (viewName) {
case 'image':
case 'diagnostics':
return cameraManager.getStore().getCameraIDs();
case 'live':
case 'clip':
case 'clips':
case 'snapshot':
case 'snapshots':
case 'recording':
case 'recordings':
const capability =
viewName === 'clip'
? 'clips'
: viewName === 'snapshot'
? 'snapshots'
: viewName === 'recording'
? 'recordings'
: viewName;
return cameraID
? cameraManager.getStore().getAllDependentCameras(cameraID, capability)
: cameraManager.getStore().getCameraIDsWithCapability(capability);
case 'timeline':
return cameraManager.getStore().getCameraIDsWithCapability({
anyCapabilities: ['clips', 'snapshots', 'recordings'],
});
case 'media':
return cameraID
? cameraManager.getStore().getAllDependentCameras(cameraID, {
anyCapabilities: ['clips', 'snapshots', 'recordings'],
})
: cameraManager.getStore().getCameraIDsWithCapability({
anyCapabilities: ['clips', 'snapshots', 'recordings'],
});
}
};