Add support for global cameras options

This commit is contained in:
Dermot Duffy
2023-03-04 10:03:16 -08:00
parent 072af6941d
commit 6a55cf9007
6 changed files with 369 additions and 266 deletions
+182 -133
View File
@@ -61,7 +61,7 @@ import {
frigateCardHasAction,
getActionConfigGivenAction,
} from './utils/action.js';
import { contentsChanged, errorToConsole } from './utils/basic.js';
import { errorToConsole } from './utils/basic.js';
import {
getEntityIcon,
getEntityTitle,
@@ -87,6 +87,10 @@ import { EntityRegistryManager } from './utils/ha/entity-registry/index.js';
import { EntityCache } from './utils/ha/entity-registry/cache.js';
import { Entity, ExtendedEntity } from './utils/ha/entity-registry/types.js';
import { getAllDependentCameras } from './utils/camera.js';
import cloneDeep from 'lodash-es/cloneDeep';
import isEqual from 'lodash-es/isEqual';
import merge from 'lodash-es/merge';
import { FrigateCardInitializer } from './utils/initializer.js';
/** A note on media callbacks:
*
@@ -135,7 +139,12 @@ console.info(
documentationURL: REPO_URL,
});
type InitializedType = 'initialized' | 'initializing';
enum InitializationAspect {
LANGUAGES = 'languages',
SIDE_LOAD_ELEMENTS = 'side-load-elements',
MEDIA_PLAYERS = 'media-players',
CAMERAS = 'cameras',
}
/**
* Main FrigateCard class.
@@ -202,9 +211,6 @@ class FrigateCard extends LitElement {
// per second for performance reasons.
protected _boundMouseHandler = throttle(this._mouseHandler.bind(this), 1 * 1000);
// Whether the card has been successfully initialized.
protected _initialized?: InitializedType;
protected _triggers: Map<string, Date> = new Map();
protected _untriggerTimerID: number | null = null;
@@ -212,6 +218,8 @@ class FrigateCard extends LitElement {
protected _mediaPlayers?: string[];
protected _initializer = new FrigateCardInitializer();
constructor() {
super();
this._entityRegistryManager = new EntityRegistryManager(
@@ -315,7 +323,15 @@ class FrigateCard extends LitElement {
// Save on Lit re-rendering costs by only updating the configuration if it
// actually changes.
if (contentsChanged(overriddenConfig, this._overriddenConfig)) {
if (!isEqual(overriddenConfig, this._overriddenConfig)) {
if (
!isEqual(overriddenConfig.cameras, this._overriddenConfig?.cameras) ||
!isEqual(overriddenConfig.cameras_global, this._overriddenConfig?.cameras_global)
) {
// Uninitialize the cameras (they will be re-initialized on the render
// cycle triggered by updating the overridden config) below.
this._initializer.uninitialize(InitializationAspect.CAMERAS);
}
this._overriddenConfig = overriddenConfig;
}
}
@@ -906,98 +922,6 @@ class FrigateCard extends LitElement {
});
}
protected async _initialize(
hass: HomeAssistant,
config: FrigateCardConfig,
cardWideConfig: CardWideConfig,
): Promise<void> {
// Above arguments are taken (vs usage of `this`) as they must exist prior
// to initialization and this ensures it is the callers responsibility to
// verify that.
await Promise.all([
// Side load Home Assistant elements used in the UI.
sideLoadHomeAssistantElements(),
// Load dynamic language imports.
loadLanguages(),
]);
await this._initializeCameras(hass, config, cardWideConfig);
// Don't reset the message which may be set to an error above. This sets the
// first view using the newly loaded cameras.
this._changeView({ resetMessage: false });
}
protected async _initializeCameras(
hass: HomeAssistant,
config: FrigateCardConfig,
cardWideConfig: CardWideConfig,
): Promise<void> {
this._cameraManager = new CameraManager(
new CameraManagerEngineFactory(this._entityRegistryManager, cardWideConfig),
this._cardWideConfig,
);
try {
await this._cameraManager.initializeCameras(
hass,
this._entityRegistryManager,
config.cameras,
);
} catch (e: unknown) {
if (e instanceof Error) {
errorToConsole(e);
}
if (e instanceof FrigateCardError) {
this._setMessageAndUpdate({
message: e.message,
type: 'error',
context: e.context,
});
}
}
}
protected async _initializeMediaPlayers(hass: HomeAssistant): Promise<void> {
const isValidMediaPlayer = (entityID: string): boolean => {
if (entityID.startsWith('media_player.')) {
const stateObj = this._hass?.states[entityID];
if (
stateObj &&
stateObj.state !== 'unavailable' &&
supportsFeature(stateObj, MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA)
) {
return true;
}
}
return false;
};
const mediaPlayers = Object.keys(this._hass?.states || {}).filter(
isValidMediaPlayer,
);
let mediaPlayerEntities: Map<string, Entity>;
try {
mediaPlayerEntities = await this._entityRegistryManager.getEntities(
hass,
mediaPlayers,
);
} catch (e) {
// Failing to fetch media player information is not considered
// sufficiently serious to block card startup.
errorToConsole(e as Error);
return;
}
// Filter out entities that are marked as hidden (this information is not
// available in the HA state, only in the registry).
this._mediaPlayers = [...mediaPlayerEntities.values()]
.filter((entity) => !entity.hidden_by)
.map((entity) => entity.entity_id);
}
/**
* Called before each update.
*/
@@ -1006,25 +930,7 @@ class FrigateCard extends LitElement {
setPerformanceCSSStyles(this, this._cardWideConfig?.performance);
}
if (
this._hass &&
!this._mediaPlayers &&
this._getConfig().menu.buttons.media_player.enabled
) {
// Media players are initialized outside the main initialization code (the
// `initialize` method) since they may be required depending on an
// overridable configuration value.
// We also want to initialize media players after since the main camera
// initialization since that may have fetched entity information that will
// be cached and re-used here (minor performance optimization). Only do
// this if the media player button is enabled to further limit the
// performance implications.
// Prevent a double initialization.
this._mediaPlayers = [];
this._initializeMediaPlayers(this._hass);
}
this._initializeBackground();
if (this._view?.is('live')) {
import('./components/live/live.js');
@@ -1147,30 +1053,173 @@ class FrigateCard extends LitElement {
}
}
protected async _initializeCameras(
hass: HomeAssistant,
config: FrigateCardConfig,
cardWideConfig: CardWideConfig,
): Promise<void> {
this._cameraManager = new CameraManager(
new CameraManagerEngineFactory(this._entityRegistryManager, cardWideConfig),
this._cardWideConfig,
);
// For each camera merge the config into the camera global config. The
// merging must happen in this order, to ensure that the defaults in the
// cameras global config do not override the values specified in the
// per-camera config.
const cameras = config.cameras.map((camera) =>
merge(cloneDeep(config.cameras_global), camera),
);
console.info("MERGED CAMERAS", cameras);
try {
await this._cameraManager.initializeCameras(
hass,
this._entityRegistryManager,
cameras,
);
} catch (e: unknown) {
if (e instanceof Error) {
errorToConsole(e);
}
if (e instanceof FrigateCardError) {
this._setMessageAndUpdate({
message: e.message,
type: 'error',
context: e.context,
});
}
}
// If there's no view set yet, set one. This will be the case on initial camera load.
if (!this._view) {
// Don't reset the message which may be set to an error above. This sets the
// first view using the newly loaded cameras.
this._changeView({ resetMessage: false });
}
}
protected async _initializeMediaPlayers(hass: HomeAssistant): Promise<void> {
const isValidMediaPlayer = (entityID: string): boolean => {
if (entityID.startsWith('media_player.')) {
const stateObj = this._hass?.states[entityID];
if (
stateObj &&
stateObj.state !== 'unavailable' &&
supportsFeature(stateObj, MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA)
) {
return true;
}
}
return false;
};
const mediaPlayers = Object.keys(this._hass?.states || {}).filter(
isValidMediaPlayer,
);
let mediaPlayerEntities: Map<string, Entity>;
try {
mediaPlayerEntities = await this._entityRegistryManager.getEntities(
hass,
mediaPlayers,
);
} catch (e) {
// Failing to fetch media player information is not considered
// sufficiently serious to block card startup.
errorToConsole(e as Error);
return;
}
// Filter out entities that are marked as hidden (this information is not
// available in the HA state, only in the registry).
this._mediaPlayers = [...mediaPlayerEntities.values()]
.filter((entity) => !entity.hidden_by)
.map((entity) => entity.entity_id);
}
/**
* Initialize the hard requirements for rendering anything.
* @returns `true` if card rendering can continue.
*/
protected _initializeMandatory(): boolean {
if (
this._initializer.isInitializedMultiple([
InitializationAspect.LANGUAGES,
InitializationAspect.SIDE_LOAD_ELEMENTS,
InitializationAspect.CAMERAS,
])
) {
return true;
}
const hass = this._hass;
const config = this._getConfig();
const cardWideConfig = this._cardWideConfig;
if (!hass || !config || !cardWideConfig) {
return false;
}
this._initializer
.initializeMultipleIfNecessary({
// Caution: Ensure nothing in this set of initializers requires
// languages since they will not yet have been initialized.
[InitializationAspect.LANGUAGES]: async () => loadLanguages,
[InitializationAspect.SIDE_LOAD_ELEMENTS]: async () =>
sideLoadHomeAssistantElements,
})
.then(() => {
return this._initializer.initializeIfNecessary(
InitializationAspect.CAMERAS,
async () => this._initializeCameras(hass, config, cardWideConfig),
);
})
.then((initialized) => {
if (initialized) {
return this.requestUpdate();
}
});
return false;
}
/**
* Initialize aspects of the card that can load in the 'background'.
* @returns `true` if card rendering can continue.
*/
protected _initializeBackground(): void {
if (this._initializer.isInitialized(InitializationAspect.MEDIA_PLAYERS)) {
return;
}
const hass = this._hass;
const config = this._getConfig();
if (!hass || !config) {
return;
}
this._initializer
.initializeMultipleIfNecessary({
...(config.menu.buttons.media_player.enabled && {
[InitializationAspect.MEDIA_PLAYERS]: async () =>
this._initializeMediaPlayers(hass),
}),
})
.then((initialized) => {
if (initialized) {
this.requestUpdate();
}
});
return;
}
/**
* Determine whether the element should be updated.
* @param changedProps The changed properties if any.
* @returns `true` if the element should be updated.
*/
protected shouldUpdate(changedProps: PropertyValues): boolean {
// Load the relevant languages. Cannot do anything until then.
if (this._initialized !== 'initialized') {
const config = this._getConfig();
if (
this._initialized !== 'initializing' &&
this._hass &&
config &&
this._cardWideConfig
) {
this._initialized = 'initializing';
this._initialize(this._hass, config, this._cardWideConfig).then(() => {
this._initialized = 'initialized';
this.requestUpdate();
});
}
if (!this._initializeMandatory()) {
return false;
}
const oldHass = changedProps.get('_hass') as HomeAssistant | undefined;
let shouldUpdate = !oldHass || changedProps.size != 1;
+6 -99
View File
@@ -4,6 +4,9 @@ import isEqual from 'lodash-es/isEqual';
import set from 'lodash-es/set';
import {
CONF_CAMERAS,
CONF_CAMERAS_GLOBAL_IMAGE,
CONF_CAMERAS_GLOBAL_JSMPEG,
CONF_CAMERAS_GLOBAL_WEBRTC_CARD,
CONF_ELEMENTS,
CONF_LIVE_AUTO_UNMUTE,
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE,
@@ -28,7 +31,6 @@ import {
import {
BUTTON_SIZE_MIN,
RawFrigateCardConfig,
RawFrigateCardConfigArray,
THUMBNAIL_WIDTH_MAX,
THUMBNAIL_WIDTH_MIN,
} from './types';
@@ -91,7 +93,6 @@ export const upgradeConfig = function (obj: RawFrigateCardConfig): boolean {
for (let i = 0; i < UPGRADES.length; i++) {
upgraded = UPGRADES[i](obj) || upgraded;
}
trimConfig(obj);
return upgraded;
};
@@ -104,28 +105,6 @@ export const isConfigUpgradeable = function (obj: RawFrigateCardConfig): boolean
return upgradeConfig(copyConfig(obj));
};
/**
* Remove empty sections from a configuration.
* @param obj Configuration object.
* @returns `true` if the configuration was modified.
*/
const trimConfig = function (obj: RawFrigateCardConfig): boolean {
const keys = Object.keys(obj);
let modified = false;
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (typeof obj[key] === 'object' && obj[key] != null) {
modified = trimConfig(obj[key] as RawFrigateCardConfig) || modified;
if (!Object.keys(obj[key] as RawFrigateCardConfig).length) {
delete obj[key];
modified = true;
}
}
}
return modified;
};
/**
* Copy a configuration.
* @param obj Configuration to copy.
@@ -523,80 +502,6 @@ const transformFrigateUIAction = (data: unknown): boolean => {
return false;
};
/**
* Move live provider options exclusively into camera configs.
* @returns An upgrade function.
*/
const upgradeCameraOptionsFromLiveToMultipleCameras = (): ((
obj: RawFrigateCardConfig,
) => boolean) => {
return function (obj: RawFrigateCardConfig): boolean {
const cameras = getConfigValue(obj, CONF_CAMERAS) as
| RawFrigateCardConfigArray
| undefined;
if (cameras === undefined) {
return false;
}
const webrtcCardConfig = getConfigValue(obj, 'live.webrtc_card') as
| RawFrigateCardConfigArray
| undefined;
const imageConfig = getConfigValue(obj, 'live.image') as
| RawFrigateCardConfigArray
| undefined;
const jsmpegConfig = getConfigValue(obj, 'live.jsmpeg') as
| RawFrigateCardConfigArray
| undefined;
if (!webrtcCardConfig && !imageConfig && !jsmpegConfig) {
return false;
}
if (webrtcCardConfig) {
cameras.forEach((camera) => {
if (
camera.live_provider === 'webrtc_card' &&
(camera.webrtc_card === undefined || typeof camera.webrtc_card === 'object')
) {
camera.webrtc_card = { ...webrtcCardConfig, ...camera.webrtc_card };
}
});
}
if (imageConfig) {
cameras.forEach((camera) => {
if (
camera.live_provider === 'image' &&
(camera.image === undefined || typeof camera.image === 'object')
) {
camera.image = { ...imageConfig, ...camera.image };
}
});
}
if (jsmpegConfig) {
cameras.forEach((camera) => {
if (
camera.live_provider === 'jsmpeg' &&
(camera.jsmpeg === undefined || typeof camera.jsmpeg === 'object')
) {
camera.jsmpeg = { ...jsmpegConfig, ...camera.jsmpeg };
}
});
}
setConfigValue(obj, CONF_CAMERAS, cameras);
deleteConfigValue(obj, 'live.webrtc_card');
deleteConfigValue(obj, 'live.image');
deleteConfigValue(obj, 'live.jsmpeg');
// Note: This upgrade is imperfect. There could be override conditions being
// set that this upgrade cannot understand, e.g. if in fullscreen mode then
// refresh a live image more frequently. Such functionality is not possible
// after this change, since camera configs cannot be overrided.
return true;
};
};
const UPGRADES = [
// v3.0.0 -> v4.0.0-rc.1
upgradeWithOverrides(
@@ -680,5 +585,7 @@ const UPGRADES = [
val === 'frigate-jsmpeg' ? 'jsmpeg' : val,
),
),
upgradeCameraOptionsFromLiveToMultipleCameras(),
upgradeMoveToWithOverrides('live.image', CONF_CAMERAS_GLOBAL_IMAGE),
upgradeMoveToWithOverrides('live.jsmpeg', CONF_CAMERAS_GLOBAL_JSMPEG),
upgradeMoveToWithOverrides('live.webrtc_card', CONF_CAMERAS_GLOBAL_WEBRTC_CARD),
];
+6
View File
@@ -39,6 +39,12 @@ export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY =
export const CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES =
`${CONF_CAMERAS}.#.triggers.entities` as const;
export const CONF_CAMERAS_GLOBAL = 'cameras_global' as const;
export const CONF_CAMERAS_GLOBAL_IMAGE = `${CONF_CAMERAS_GLOBAL}.image` as const;
export const CONF_CAMERAS_GLOBAL_JSMPEG = `${CONF_CAMERAS_GLOBAL}.jsmpeg` as const;
export const CONF_CAMERAS_GLOBAL_WEBRTC_CARD =
`${CONF_CAMERAS_GLOBAL}.webrtc_card` as const;
export const CONF_ELEMENTS = 'elements' as const;
const CONF_VIEW = 'view' as const;
+6 -5
View File
@@ -412,10 +412,8 @@ const go2rtcConfigSchema = z.object({
modes: z.enum(['webrtc', 'mse', 'mp4', 'mjpeg']).array().optional(),
stream: z.string().optional(),
});
export type Go2rtcConfig = z.infer<typeof go2rtcConfigSchema>;
const liveImageConfigSchema = imageBaseConfigSchema;
export type LiveImageConfig = z.infer<typeof liveImageConfigSchema>;
const webrtcCardConfigSchema = z
.object({
@@ -423,7 +421,6 @@ const webrtcCardConfigSchema = z
url: z.string().optional(),
})
.passthrough();
export type WebRTCCardConfig = z.infer<typeof webrtcCardConfigSchema>;
const jsmpegConfigSchema = z.object({
options: z
@@ -444,7 +441,6 @@ const jsmpegConfigSchema = z.object({
})
.optional(),
});
export type JSMPEGConfig = z.infer<typeof jsmpegConfigSchema>;
/**
* Camera configuration section
@@ -524,7 +520,9 @@ const cameraConfigSchema = z
.default(cameraConfigDefault);
export type CameraConfig = z.infer<typeof cameraConfigSchema>;
const camerasConfigSchema = cameraConfigSchema.array().nonempty();
// Avoid using .nonempty() to avoid changing the inferred type
// (https://github.com/colinhacks/zod#minmaxlength).
const camerasConfigSchema = cameraConfigSchema.array().min(1);
export type CamerasConfig = z.infer<typeof camerasConfigSchema>;
/**
@@ -1200,6 +1198,8 @@ export type TimelineConfig = z.infer<typeof timelineConfigSchema>;
// Strip all defaults from the override schemas, to ensure values are only what
// the user has specified.
const overrideConfigurationSchema = z.object({
cameras: deepRemoveDefaults(camerasConfigSchema).optional(),
cameras_global: deepRemoveDefaults(cameraConfigSchema).optional(),
live: deepRemoveDefaults(liveOverridableConfigSchema).optional(),
menu: deepRemoveDefaults(menuConfigSchema).optional(),
image: deepRemoveDefaults(imageConfigSchema).optional(),
@@ -1284,6 +1284,7 @@ export interface CardWideConfig {
export const frigateCardConfigSchema = z.object({
// Main configuration sections.
cameras: camerasConfigSchema,
cameras_global: cameraConfigSchema.optional(),
view: viewConfigSchema,
menu: menuConfigSchema,
live: liveConfigSchema,
+69
View File
@@ -0,0 +1,69 @@
import { allPromises } from './basic';
enum InitializationState {
INITIALIZING = 'initializing',
INITIALIZED = 'initialized',
}
type Initializer = () => Promise<unknown>;
/**
* Manages initialization state & calling initializers.
*/
export class FrigateCardInitializer {
protected _state: Map<string, InitializationState>;
constructor() {
this._state = new Map();
}
public async initializeMultipleIfNecessary(
aspects: Record<string, Initializer>,
): Promise<boolean> {
const results = await allPromises(
Object.entries(aspects),
async ([aspect, options]) => this.initializeIfNecessary(aspect, options),
);
return results.every(Boolean);
}
/**
*
* @param aspect The aspect to initialize.
* @param initializer The initializer to call.
* @returns `true` if the state is confirmed as initialized, `false`
* otherwise (i.e. initializing).
*/
public async initializeIfNecessary(
aspect: string,
initializer?: Initializer,
): Promise<boolean> {
const state = this._state.get(aspect);
if (state !== InitializationState.INITIALIZED) {
if (state !== InitializationState.INITIALIZING) {
if (initializer) {
this._state.set(aspect, InitializationState.INITIALIZING);
await initializer();
this._state.set(aspect, InitializationState.INITIALIZED);
} else {
this._state.set(aspect, InitializationState.INITIALIZED);
}
return true;
}
return false;
}
return true;
}
public uninitialize(aspect: string) {
return this._state.delete(aspect);
}
public isInitialized(aspect: string): boolean {
return this._state.get(aspect) == InitializationState.INITIALIZED;
}
public isInitializedMultiple(aspects: string[]): boolean {
return aspects.every((aspect) => this.isInitialized(aspect));
}
}