Add support for more advanced forms of overriding

This commit is contained in:
Dermot Duffy
2024-04-13 18:23:11 -07:00
parent 0eca5d780c
commit 323925101d
21 changed files with 855 additions and 285 deletions
+1 -1
View File
@@ -145,7 +145,7 @@ export class CameraManager {
// 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) =>
recursivelyMergeObjectsNotArrays(cloneDeep(config?.cameras_global), camera),
recursivelyMergeObjectsNotArrays({}, cloneDeep(config?.cameras_global), camera),
);
try {
+60 -31
View File
@@ -1,14 +1,21 @@
import { CurrentUser } from '@dermotduffy/custom-card-helpers';
import { HassEntities } from 'home-assistant-js-websocket';
import merge from 'lodash-es/merge';
import { copyConfig } from '../config/management';
import { ZodSchema } from 'zod';
import {
copyConfig,
deleteConfigValue,
getConfigValue,
setConfigValue,
} from '../config/management';
import {
FrigateCardCondition,
frigateConditionalSchema,
OverrideConfigurationKey,
RawFrigateCardConfig,
ViewDisplayMode,
frigateConditionalSchema,
Overrides,
} from '../config/types';
import { desparsifyArrays } from '../utils/basic';
import { CardConditionAPI } from './types';
interface MicrophoneConditionState {
@@ -73,44 +80,66 @@ export function evaluateConditionViaEvent(
return evaluateEvent.evaluation ?? false;
}
type RawOverrides = {
conditions: FrigateCardCondition[];
overrides: RawFrigateCardConfig;
}[];
export function getOverriddenConfig(
manager: Readonly<ConditionsManager>,
config: Readonly<RawFrigateCardConfig>,
configOverrides?: Readonly<RawOverrides>,
stateOverrides?: Partial<ConditionState>,
options?: {
configOverrides?: Readonly<Overrides>;
stateOverrides?: Partial<ConditionState>;
schema?: ZodSchema;
logOnParseError?: boolean;
},
): RawFrigateCardConfig {
const output = copyConfig(config);
let output = copyConfig(config);
let overridden = false;
if (configOverrides) {
for (const override of configOverrides) {
if (manager.evaluateConditions(override.conditions, stateOverrides)) {
merge(output, override.overrides);
if (options?.configOverrides) {
for (const override of options.configOverrides) {
if (manager.evaluateConditions(override.conditions, options?.stateOverrides)) {
override.delete?.forEach((deletionKey) => {
deleteConfigValue(output, deletionKey);
});
Object.keys(override.set ?? {}).forEach((setKey) => {
setConfigValue(output, setKey, override.set?.[setKey]);
});
Object.keys(override.merge ?? {}).forEach((mergeKey) => {
setConfigValue(
output,
mergeKey,
merge({}, getConfigValue(output, mergeKey), override.merge?.[mergeKey]),
);
});
overridden = true;
}
}
}
// Attempt to return the same configuration object if it has not been
// overridden (to reduce re-renders for a configuration that has not changed).
return overridden ? output : config;
}
export function getOverridesByKey(
key: OverrideConfigurationKey,
overrides?: Readonly<RawOverrides>,
): RawOverrides {
return (
overrides
?.filter((o) => key in o.overrides)
.map((o) => ({
conditions: o.conditions,
overrides: o.overrides[key] as RawFrigateCardConfig,
})) ?? []
);
if (!overridden) {
// Attempt to return the same configuration object if it has not been
// overridden (to reduce re-renders for a configuration that has not changed).
return config;
}
if (options?.configOverrides?.some((override) => override.delete?.length)) {
// If anything was deleted during this override, empty undefined slots may
// be left in arrays where values were unset. Desparsify them.
output = desparsifyArrays(output);
}
if (options?.schema) {
const parseResult = options.schema.safeParse(output);
if (options.logOnParseError && !parseResult.success) {
console.warn(
`Cannot parse overridden configuration`,
output,
parseResult.error.message,
);
}
return parseResult.success ? parseResult.data : config;
}
return output;
}
// A tiny wrapper interface to allow the same manager to be passed around
+6 -6
View File
@@ -4,7 +4,7 @@ import {
CardWideConfig,
FrigateCardConfig,
frigateCardConfigSchema,
RawFrigateCardConfig
RawFrigateCardConfig,
} from '../config/types';
import { localize } from '../localize/localize';
import { setProfiles } from '../config/profiles';
@@ -104,11 +104,11 @@ export class ConfigManager {
if (!this._config) {
return;
}
const overriddenConfig = getOverriddenConfig(
conditionsManager,
this._config,
this._config.overrides,
) as FrigateCardConfig;
const overriddenConfig = getOverriddenConfig(conditionsManager, this._config, {
configOverrides: this._config.overrides,
schema: frigateCardConfigSchema,
logOnParseError: !!this.getCardWideConfig()?.debug?.logging,
}) as FrigateCardConfig;
// Save on Lit re-rendering costs by only updating the configuration if it
// actually changes.
+18 -13
View File
@@ -26,8 +26,9 @@ import {
CardWideConfig,
frigateCardConfigDefaults,
LiveConfig,
LiveOverrides,
liveConfigAbsoluteRootSchema,
LiveProvider,
Overrides,
TransitionEffect,
} from '../../config/types.js';
import { localize } from '../../localize/localize.js';
@@ -80,7 +81,7 @@ export class FrigateCardLive extends LitElement {
public overriddenLiveConfig?: LiveConfig;
@property({ attribute: false, hasChanged: contentsChanged })
public liveOverrides?: LiveOverrides;
public overrides?: Overrides;
@property({ attribute: false })
public cameraManager?: CameraManager;
@@ -151,7 +152,7 @@ export class FrigateCardLive extends LitElement {
.overriddenLiveConfig=${this.overriddenLiveConfig}
.inBackground=${this._controller.isInBackground()}
.conditionsManagerEpoch=${this.conditionsManagerEpoch}
.liveOverrides=${this.liveOverrides}
.overrides=${this.overrides}
.cardWideConfig=${this.cardWideConfig}
.cameraManager=${this.cameraManager}
.microphoneManager=${this.microphoneManager}
@@ -182,7 +183,7 @@ export class FrigateCardLiveGrid extends LitElement {
public overriddenLiveConfig?: LiveConfig;
@property({ attribute: false, hasChanged: contentsChanged })
public liveOverrides?: LiveOverrides;
public overrides?: Overrides;
@property({ attribute: false })
public conditionsManagerEpoch?: ConditionsManagerEpoch;
@@ -211,7 +212,7 @@ export class FrigateCardLiveGrid extends LitElement {
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
.overriddenLiveConfig=${this.overriddenLiveConfig}
.conditionsManagerEpoch=${this.conditionsManagerEpoch}
.liveOverrides=${this.liveOverrides}
.overrides=${this.overrides}
.cardWideConfig=${this.cardWideConfig}
.cameraManager=${this.cameraManager}
.microphoneManager=${this.microphoneManager}
@@ -290,7 +291,7 @@ export class FrigateCardLiveCarousel extends LitElement {
public overriddenLiveConfig?: LiveConfig;
@property({ attribute: false, hasChanged: contentsChanged })
public liveOverrides?: LiveOverrides;
public overrides?: Overrides;
@property({ attribute: false })
public conditionsManagerEpoch?: ConditionsManagerEpoch;
@@ -471,19 +472,23 @@ export class FrigateCardLiveCarousel extends LitElement {
// (in the carousel for example) is not necessarily the live camera *this*
// <frigate-card-live-provider> is rendering right now, so we provide a
// stateOverride to evaluate the condition in that context.
const config = getOverriddenConfig(
const liveConfig = getOverriddenConfig(
this.conditionsManagerEpoch.manager,
this.nonOverriddenLiveConfig,
this.liveOverrides,
{ camera: cameraID },
) as LiveConfig;
{ live: this.nonOverriddenLiveConfig },
{
configOverrides: this.overrides,
stateOverrides: { camera: cameraID },
schema: liveConfigAbsoluteRootSchema,
logOnParseError: !!this.cardWideConfig?.debug?.logging,
},
).live as LiveConfig;
const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID);
return html`
<div class="embla__slide">
<frigate-card-live-provider
?load=${!config.lazy_load}
?load=${!liveConfig.lazy_load}
.microphoneStream=${this.view?.camera === cameraID
? this.microphoneManager?.getStream()
: undefined}
@@ -493,7 +498,7 @@ export class FrigateCardLiveCarousel extends LitElement {
() => this.cameraManager?.getCameraEndpoints(cameraID) ?? undefined,
)}
.label=${cameraMetadata?.title ?? ''}
.liveConfig=${config}
.liveConfig=${liveConfig}
.hass=${this.hass}
.cardWideConfig=${this.cardWideConfig}
>
+2 -12
View File
@@ -9,10 +9,7 @@ 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 } from '../card-controller/conditions-manager.js';
import { ReadonlyMicrophoneManager } from '../card-controller/microphone-manager.js';
import {
CardWideConfig,
@@ -233,10 +230,7 @@ export class FrigateCardViews extends LitElement {
.nonOverriddenLiveConfig=${this.nonOverriddenConfig.live}
.overriddenLiveConfig=${this.overriddenConfig.live}
.conditionsManagerEpoch=${this.conditionsManagerEpoch}
.liveOverrides=${getOverridesByKey(
'live',
this.overriddenConfig.overrides,
)}
.overrides=${this.overriddenConfig.overrides}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
.microphoneManager=${this.microphoneManager}
@@ -248,10 +242,6 @@ export class FrigateCardViews extends LitElement {
: ``
}
</frigate-card-surround>`;
// .fetchMediaType=${this.view?.is('live') ? this.overriddenConfig.live.controls.thumbnails.media_type : undefined}
// .fetchEventsMediaType=${this.view?.is('live') ? this.overriddenConfig.live.controls.thumbnails.events_media_type : undefined}
}
static get styles(): CSSResultGroup {
+1 -1
View File
@@ -42,7 +42,6 @@ import { arrayify } from '../utils/basic';
* @param keys The key to the property to set.
* @param value The value to set.
*/
export const setConfigValue = (
obj: RawFrigateCardConfig,
keys: string | (string | number)[],
@@ -689,4 +688,5 @@ const UPGRADES = [
// Delete the value if it's set to the default.
transform: (val) => (val === 'low' ? ['low-performance'] : null),
}),
upgradeArrayOfObjects(CONF_OVERRIDES, upgradeMoveTo('overrides', 'merge')),
];
+38 -51
View File
@@ -1008,8 +1008,24 @@ const livethumbnailsControlSchema = thumbnailsControlSchema.extend({
),
});
const liveOverridableConfigSchema = z
const liveConfigSchema = z
.object({
auto_pause: z
.enum(MEDIA_ACTION_NEGATIVE_CONDITIONS)
.array()
.default(liveConfigDefault.auto_pause),
auto_play: z
.enum(MEDIA_ACTION_POSITIVE_CONDITIONS)
.array()
.default(liveConfigDefault.auto_play),
auto_mute: z
.enum(MEDIA_MUTE_CONDITIONS)
.array()
.default(liveConfigDefault.auto_mute),
auto_unmute: z
.enum(MEDIA_UNMUTE_CONDITIONS)
.array()
.default(liveConfigDefault.auto_unmute),
controls: z
.object({
builtin: z.boolean().default(liveConfigDefault.controls.builtin),
@@ -1032,55 +1048,36 @@ const liveOverridableConfigSchema = z
title: titleControlConfigSchema.optional(),
})
.default(liveConfigDefault.controls),
show_image_during_load: z
.boolean()
.default(liveConfigDefault.show_image_during_load),
microphone: microphoneConfigSchema.default(liveConfigDefault.microphone),
zoomable: z.boolean().default(liveConfigDefault.zoomable),
display: viewDisplaySchema,
})
.merge(actionsSchema);
const liveConfigSchema = liveOverridableConfigSchema
.extend({
auto_play: z
.enum(MEDIA_ACTION_POSITIVE_CONDITIONS)
.array()
.default(liveConfigDefault.auto_play),
auto_pause: z
.enum(MEDIA_ACTION_NEGATIVE_CONDITIONS)
.array()
.default(liveConfigDefault.auto_pause),
auto_mute: z
.enum(MEDIA_MUTE_CONDITIONS)
.array()
.default(liveConfigDefault.auto_mute),
auto_unmute: z
.enum(MEDIA_UNMUTE_CONDITIONS)
.array()
.default(liveConfigDefault.auto_unmute),
preload: z.boolean().default(liveConfigDefault.preload),
draggable: z.boolean().default(liveConfigDefault.draggable),
lazy_load: z.boolean().default(liveConfigDefault.lazy_load),
lazy_unload: z
.enum(MEDIA_ACTION_NEGATIVE_CONDITIONS)
.array()
.default(liveConfigDefault.lazy_unload),
draggable: z.boolean().default(liveConfigDefault.draggable),
microphone: microphoneConfigSchema.default(liveConfigDefault.microphone),
preload: z.boolean().default(liveConfigDefault.preload),
show_image_during_load: z
.boolean()
.default(liveConfigDefault.show_image_during_load),
transition_effect: transitionEffectConfigSchema.default(
liveConfigDefault.transition_effect,
),
zoomable: z.boolean().default(liveConfigDefault.zoomable),
})
.merge(actionsSchema)
.default(liveConfigDefault);
export type LiveConfig = z.infer<typeof liveConfigSchema>;
const liveOverridesSchema = z
.object({
conditions: frigateCardConditionSchema.array(),
overrides: liveOverridableConfigSchema,
})
.array()
.optional();
export type LiveOverrides = z.infer<typeof liveOverridesSchema>;
// This schema is used when the live config needs to be overridden (see
// `live.ts`). Overrides will always be "relative" to the config root, so this
// schema maintains that 'depth' from the root but without the other
// requirements that frigateCardConfigSchema has. Without this, overrides
// calculated in `live.ts` would fail since cameras/type are not provided (as
// these are mandatory parameters in the full config).
export const liveConfigAbsoluteRootSchema = z.object({
live: liveConfigSchema,
});
// *************************************************************************
// Cast Configuration
@@ -1580,26 +1577,16 @@ export const dimensionsConfigSchema = z
// Override Configuration
// *************************************************************************
// 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(),
view: deepRemoveDefaults(viewConfigSchema).optional(),
dimensions: deepRemoveDefaults(dimensionsConfigSchema).optional(),
});
export type OverrideConfigurationKey = keyof z.infer<typeof overrideConfigurationSchema>;
const overridesSchema = z
.object({
conditions: frigateCardConditionSchema.array(),
overrides: overrideConfigurationSchema,
merge: z.object({}).passthrough().optional(),
set: z.object({}).passthrough().optional(),
delete: z.string().array().optional(),
})
.array()
.optional();
export type Overrides = z.infer<typeof overridesSchema>;
// *************************************************************************
// Automation Configuration
+20 -2
View File
@@ -243,8 +243,8 @@ export const getChildrenFromElement = (parent: HTMLElement): HTMLElement[] => {
return children.filter(isHTMLElement);
};
export const recursivelyMergeObjectsNotArrays = <T>(src1: T, src2: T): T => {
return mergeWith({}, src1, src2, (_a, b) => (Array.isArray(b) ? b : undefined));
export const recursivelyMergeObjectsNotArrays = <T>(target: T, src1: T, src2: T): T => {
return mergeWith(target, src1, src2, (_a, b) => (Array.isArray(b) ? b : undefined));
};
export const aspectRatioToString = (options?: {
@@ -268,3 +268,21 @@ export const aspectRatioToStyle = (options?: {
'aspect-ratio': aspectRatioToString(options),
};
};
/**
* Remove empty slots from nested arrays.
*/
export const desparsifyArrays = <T>(data: T): T => {
if (Array.isArray(data)) {
return <T>(
data.filter((item) => item !== undefined).map((item) => desparsifyArrays(item))
);
} else if (typeof data === 'object' && data !== null) {
const result: Record<string | number | symbol, unknown> = {};
for (const key in data) {
result[key] = desparsifyArrays(data[key]);
}
return <T>result;
}
return data;
};
+4 -1
View File
@@ -28,7 +28,10 @@ export function deepRemoveDefaults<T extends z.ZodTypeAny>(schema: T): any {
}
if (schema instanceof z.ZodArray) {
return z.ZodArray.create(deepRemoveDefaults(schema.element));
return z.ZodArray.create(deepRemoveDefaults(schema.element))
.min(schema._def.minLength?.value, schema._def.minLength?.message)
.max(schema._def.maxLength?.value, schema._def.maxLength?.message)
.length(schema._def.exactLength?.value, schema._def.exactLength?.message);
}
if (schema instanceof z.ZodOptional) {