refactor: Significantly refactor how conditions work internally (#1886)
- Much improved API cleanliness and testability to allow further extensibility in future - Simplified code in `live` view This technically contains a small change in how overrides work in the `live` view. Since that change is _closer_ to the documentation, and since this is likely to be rarely used, this is not considered a breaking change. Previously, overrides for a given live camera would always render _as if_ that camera was selected, vs was actually selected. Now, overrides will only apply in the live view when the camera is _actually_ selected. If this is an issue for you in practice, lets discuss.
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
import { ConditionsManager } from '../conditions/conditions-manager.js';
|
||||
import { ConditionsEvaluationResult } from '../conditions/types.js';
|
||||
import { Automation, AutomationActions } from '../config/types.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import { CardAutomationsAPI, TaggedAutomations } from './types.js';
|
||||
import { CardAutomationsAPI, TaggedAutomation } from './types.js';
|
||||
|
||||
const MAX_NESTED_AUTOMATION_EXECUTIONS = 10;
|
||||
|
||||
export class AutomationsManager {
|
||||
protected _api: CardAutomationsAPI;
|
||||
|
||||
protected _automations: TaggedAutomations = [];
|
||||
protected _priorEvaluations: Map<Automation, boolean> = new Map();
|
||||
protected _automations = new Map<TaggedAutomation, ConditionsManager>();
|
||||
|
||||
// A counter to avoid infinite loops, increases every time actions are run,
|
||||
// decreases every time actions are complete.
|
||||
@@ -19,14 +20,28 @@ export class AutomationsManager {
|
||||
}
|
||||
|
||||
public deleteAutomations(tag?: unknown) {
|
||||
this._automations = this._automations.filter((automation) => automation.tag !== tag);
|
||||
for (const [automation, conditionManager] of this._automations) {
|
||||
if (automation.tag === tag) {
|
||||
this._automations.delete(automation);
|
||||
conditionManager.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public addAutomations(automations: TaggedAutomations): void {
|
||||
this._automations.push(...automations);
|
||||
public addAutomations(automations: TaggedAutomation[]): void {
|
||||
for (const automation of automations) {
|
||||
const conditionManager = new ConditionsManager(
|
||||
automation.conditions,
|
||||
this._api.getConditionStateManager(),
|
||||
);
|
||||
conditionManager.addListener((result: ConditionsEvaluationResult) =>
|
||||
this._execute(automation, result),
|
||||
);
|
||||
this._automations.set(automation, conditionManager);
|
||||
}
|
||||
}
|
||||
|
||||
public execute(): void {
|
||||
protected _execute(automation: Automation, result: ConditionsEvaluationResult): void {
|
||||
if (
|
||||
!this._api.getHASSManager().hasHASS() ||
|
||||
// Never execute automations if the card hasn't finished initializing, as
|
||||
@@ -40,20 +55,10 @@ export class AutomationsManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const actionsToRun: AutomationActions = [];
|
||||
for (const automation of this._automations) {
|
||||
const shouldExecute = this._api
|
||||
.getConditionsManager()
|
||||
.evaluateConditions(automation.conditions);
|
||||
const actions = shouldExecute ? automation.actions : automation.actions_not;
|
||||
const priorEvaluation = this._priorEvaluations.get(automation);
|
||||
this._priorEvaluations.set(automation, shouldExecute);
|
||||
if (shouldExecute !== priorEvaluation && actions) {
|
||||
actionsToRun.push(...actions);
|
||||
}
|
||||
}
|
||||
const shouldExecute = result.result;
|
||||
const actions = shouldExecute ? automation.actions : automation.actions_not;
|
||||
|
||||
if (!actionsToRun.length) {
|
||||
if (!actions?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -70,6 +75,6 @@ export class AutomationsManager {
|
||||
await this._api.getActionsManager().executeActions(actions);
|
||||
--this._nestedAutomationExecutions;
|
||||
};
|
||||
runActions(actionsToRun);
|
||||
runActions(actions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ export class CardElementManager {
|
||||
this._api.getQueryStringManager().requestExecution,
|
||||
);
|
||||
|
||||
this._api.getConditionsManager()?.setState({
|
||||
this._api.getConditionStateManager()?.setState({
|
||||
userAgent: navigator.userAgent,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
import { CurrentUser } from '@dermotduffy/custom-card-helpers';
|
||||
import { HassEntities } from 'home-assistant-js-websocket';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import merge from 'lodash-es/merge';
|
||||
import { ZodSchema } from 'zod';
|
||||
import {
|
||||
copyConfig,
|
||||
deleteConfigValue,
|
||||
getConfigValue,
|
||||
setConfigValue,
|
||||
} from '../config/management';
|
||||
import {
|
||||
AdvancedCameraCardCondition,
|
||||
advancedCameraCardConditionalSchema,
|
||||
Overrides,
|
||||
RawAdvancedCameraCardConfig,
|
||||
ViewDisplayMode,
|
||||
} from '../config/types';
|
||||
import { localize } from '../localize/localize';
|
||||
import { AdvancedCameraCardError, MediaLoadedInfo } from '../types';
|
||||
import { desparsifyArrays } from '../utils/basic';
|
||||
import { isCompanionApp } from '../utils/companion';
|
||||
import { CardConditionAPI, KeysState, MicrophoneState } from './types';
|
||||
|
||||
export interface ConditionState {
|
||||
camera?: string;
|
||||
displayMode?: ViewDisplayMode;
|
||||
expand?: boolean;
|
||||
fullscreen?: boolean;
|
||||
interaction?: boolean;
|
||||
keys?: KeysState;
|
||||
mediaLoadedInfo?: MediaLoadedInfo | null;
|
||||
microphone?: MicrophoneState;
|
||||
state?: HassEntities;
|
||||
triggered?: Set<string>;
|
||||
user?: CurrentUser;
|
||||
userAgent?: string;
|
||||
view?: string;
|
||||
}
|
||||
|
||||
class OverrideConfigurationError extends AdvancedCameraCardError {}
|
||||
|
||||
export class ConditionsEvaluateRequestEvent extends Event {
|
||||
public conditions: AdvancedCameraCardCondition[];
|
||||
public evaluation?: boolean;
|
||||
|
||||
constructor(conditions: AdvancedCameraCardCondition[], eventInitDict?: EventInit) {
|
||||
super('advanced-camera-card:conditions:evaluate', eventInitDict);
|
||||
this.conditions = conditions;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate whether an AdvancedCameraCardCondition is met using an event to
|
||||
* evaluate.
|
||||
* @returns A boolean indicating whether the condition is met.
|
||||
*/
|
||||
export function evaluateConditionViaEvent(
|
||||
element: HTMLElement,
|
||||
conditions?: AdvancedCameraCardCondition[],
|
||||
): boolean {
|
||||
if (!conditions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const evaluateEvent = new ConditionsEvaluateRequestEvent(conditions, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
});
|
||||
|
||||
/* Special note on what's going on here:
|
||||
*
|
||||
* Some parts of the card (e.g. <advanced-camera-card-elements>) may have arbitrary
|
||||
* complexity and layers (that this card doesn't control) between that master
|
||||
* element and the element that needs to evaluate the condition. In these
|
||||
* cases there's no clean way to pass state from the rest of card down through
|
||||
* these layers. Instead, an event is dispatched as a "request for evaluation"
|
||||
* (ConditionEvaluateRequestEvent) upwards which is caught by the outer card
|
||||
* and the evaluation result is added to the event object. Because event
|
||||
* propagation is handled synchronously, the result will be added to the event
|
||||
* before the flow proceeds.
|
||||
*/
|
||||
element.dispatchEvent(evaluateEvent);
|
||||
return evaluateEvent.evaluation ?? false;
|
||||
}
|
||||
|
||||
export function getOverriddenConfig<RT extends RawAdvancedCameraCardConfig>(
|
||||
manager: Readonly<ConditionsManager>,
|
||||
config: Readonly<RT>,
|
||||
options?: {
|
||||
configOverrides?: Readonly<Overrides>;
|
||||
stateOverrides?: Partial<ConditionState>;
|
||||
schema?: ZodSchema;
|
||||
},
|
||||
): RT {
|
||||
let output = copyConfig(config);
|
||||
let overridden = false;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 (!parseResult.success) {
|
||||
throw new OverrideConfigurationError(
|
||||
localize('error.invalid_configuration_override'),
|
||||
[parseResult.error.errors, output],
|
||||
);
|
||||
}
|
||||
return parseResult.data;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
// A tiny wrapper interface to allow the same manager to be passed around
|
||||
// immutably within objects that will not be equal (===). Every state change
|
||||
// generates a new epoch. This is used for Lit rendering to ensure changes to
|
||||
// condition state are recognized as changes even though the manager is the
|
||||
// same.
|
||||
export interface ConditionsManagerEpoch {
|
||||
manager: Readonly<ConditionsManager>;
|
||||
}
|
||||
|
||||
export type ConditionsManagerListener = (
|
||||
newState: ConditionState,
|
||||
oldState?: ConditionState,
|
||||
) => void;
|
||||
|
||||
export class ConditionsManager {
|
||||
protected _api: CardConditionAPI;
|
||||
|
||||
protected _state: ConditionState = {};
|
||||
protected _epoch: ConditionsManagerEpoch = this._createEpoch();
|
||||
protected _listeners: ConditionsManagerListener[];
|
||||
|
||||
// Whether or not to include HA state in ConditionState. Doing so increases
|
||||
// CPU usage as HA state is pumped out very fast, so this is only enabled if
|
||||
// the configuration needs to consume it.
|
||||
protected _hasHAStateConditions = false;
|
||||
protected _mediaQueries: MediaQueryList[] = [];
|
||||
protected _mediaQueryTrigger = () => this._triggerChange(this._state);
|
||||
|
||||
constructor(api: CardConditionAPI, listener?: ConditionsManagerListener) {
|
||||
this._api = api;
|
||||
this._listeners = [
|
||||
() => this._api.getConfigManager().computeOverrideConfig(),
|
||||
() => this._api.getAutomationsManager().execute(),
|
||||
...(listener ? [listener] : []),
|
||||
];
|
||||
}
|
||||
|
||||
public addListener(listener: ConditionsManagerListener): void {
|
||||
this._listeners.push(listener);
|
||||
}
|
||||
|
||||
public removeListener(listener: ConditionsManagerListener): void {
|
||||
this._listeners = this._listeners.filter((l) => l !== listener);
|
||||
}
|
||||
|
||||
public removeConditions(): void {
|
||||
this._mediaQueries.forEach((mql) =>
|
||||
mql.removeEventListener('change', this._mediaQueryTrigger),
|
||||
);
|
||||
this._mediaQueries = [];
|
||||
}
|
||||
|
||||
public setConditionsFromConfig(): void {
|
||||
this.removeConditions();
|
||||
|
||||
const getAllConditions = (): AdvancedCameraCardCondition[] => {
|
||||
const config = this._api.getConfigManager().getConfig();
|
||||
const conditions: AdvancedCameraCardCondition[] = [];
|
||||
config?.overrides?.forEach((override) => conditions.push(...override.conditions));
|
||||
config?.automations?.forEach((automation) =>
|
||||
conditions.push(...automation.conditions),
|
||||
);
|
||||
|
||||
// Element conditions can be arbitrarily nested underneath conditionals and
|
||||
// custom elements that this card may not known. Here we recursively parse
|
||||
// down the elements tree, parsing as we go to find valid conditions.
|
||||
const getElementsConditions = (data: unknown): void => {
|
||||
const parseResult = advancedCameraCardConditionalSchema.safeParse(data);
|
||||
if (parseResult.success) {
|
||||
conditions.push(...parseResult.data.conditions);
|
||||
parseResult.data.elements?.forEach(getElementsConditions);
|
||||
} else if (data && typeof data === 'object') {
|
||||
Object.keys(data).forEach((key) => getElementsConditions(data[key]));
|
||||
}
|
||||
};
|
||||
config?.elements?.forEach(getElementsConditions);
|
||||
return conditions;
|
||||
};
|
||||
|
||||
const conditions = getAllConditions();
|
||||
this._hasHAStateConditions = conditions.some(
|
||||
(conditionObj) =>
|
||||
!conditionObj.condition ||
|
||||
['state', 'numeric_state', 'user'].includes(conditionObj.condition),
|
||||
);
|
||||
conditions.forEach((conditionObj) => {
|
||||
if (conditionObj.condition === 'screen') {
|
||||
const mql = window.matchMedia(conditionObj.media_query);
|
||||
mql.addEventListener('change', this._mediaQueryTrigger);
|
||||
this._mediaQueries.push(mql);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public setState(state: Partial<ConditionState>): void {
|
||||
// Performance: Compare the new state with the existing state and only
|
||||
// trigger a change if the new state is different. Only the new keys are
|
||||
// compared, since some of the values (e.g. 'state') will be large.
|
||||
if (Object.keys(state).every((key) => isEqual(state[key], this._state[key]))) {
|
||||
return;
|
||||
}
|
||||
const oldState = this._state;
|
||||
this._state = {
|
||||
...oldState,
|
||||
...state,
|
||||
};
|
||||
this._triggerChange(oldState);
|
||||
}
|
||||
|
||||
public getState(): ConditionState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
public hasHAStateConditions(): boolean {
|
||||
return this._hasHAStateConditions;
|
||||
}
|
||||
|
||||
public getEpoch(): ConditionsManagerEpoch {
|
||||
return this._epoch;
|
||||
}
|
||||
|
||||
public evaluateConditions(
|
||||
conditions: Readonly<AdvancedCameraCardCondition>[],
|
||||
stateOverrides?: Partial<ConditionState>,
|
||||
): boolean {
|
||||
return conditions.every((conditionObj) =>
|
||||
this._evaluateCondition(conditionObj, stateOverrides),
|
||||
);
|
||||
}
|
||||
|
||||
protected _evaluateCondition(
|
||||
conditionObj: Readonly<AdvancedCameraCardCondition>,
|
||||
stateOverrides?: Partial<ConditionState>,
|
||||
): boolean {
|
||||
const state = {
|
||||
...this._state,
|
||||
...stateOverrides,
|
||||
};
|
||||
|
||||
switch (conditionObj.condition) {
|
||||
case undefined:
|
||||
case 'state':
|
||||
return (
|
||||
!!state.state &&
|
||||
((!conditionObj.state && !conditionObj.state_not) ||
|
||||
(conditionObj.entity in state.state &&
|
||||
(!conditionObj.state ||
|
||||
(Array.isArray(conditionObj.state)
|
||||
? conditionObj.state.includes(state.state[conditionObj.entity].state)
|
||||
: conditionObj.state === state.state[conditionObj.entity].state)) &&
|
||||
(!conditionObj.state_not ||
|
||||
(Array.isArray(conditionObj.state_not)
|
||||
? !conditionObj.state_not.includes(
|
||||
state.state[conditionObj.entity].state,
|
||||
)
|
||||
: conditionObj.state_not !== state.state[conditionObj.entity].state))))
|
||||
);
|
||||
case 'view':
|
||||
return !!state?.view && conditionObj.views.includes(state.view);
|
||||
case 'fullscreen':
|
||||
return (
|
||||
state.fullscreen !== undefined && conditionObj.fullscreen === state.fullscreen
|
||||
);
|
||||
case 'expand':
|
||||
return state.expand !== undefined && conditionObj.expand === state.expand;
|
||||
case 'camera':
|
||||
return !!state.camera && conditionObj.cameras.includes(state.camera);
|
||||
case 'numeric_state':
|
||||
return (
|
||||
!!state.state &&
|
||||
conditionObj.entity in state.state &&
|
||||
state.state[conditionObj.entity].state !== undefined &&
|
||||
(conditionObj.above === undefined ||
|
||||
Number(state.state[conditionObj.entity].state) > conditionObj.above) &&
|
||||
(conditionObj.below === undefined ||
|
||||
Number(state.state[conditionObj.entity].state) < conditionObj.below)
|
||||
);
|
||||
case 'user':
|
||||
return !!state.user && conditionObj.users.includes(state.user.id);
|
||||
case 'media_loaded':
|
||||
return (
|
||||
state.mediaLoadedInfo !== undefined &&
|
||||
conditionObj.media_loaded === !!state.mediaLoadedInfo
|
||||
);
|
||||
case 'screen':
|
||||
return window.matchMedia(conditionObj.media_query).matches;
|
||||
case 'display_mode':
|
||||
return !!state.displayMode && conditionObj.display_mode === state.displayMode;
|
||||
case 'triggered':
|
||||
return conditionObj.triggered.some((triggeredCameraID) =>
|
||||
state.triggered?.has(triggeredCameraID),
|
||||
);
|
||||
case 'interaction':
|
||||
return (
|
||||
state.interaction !== undefined &&
|
||||
conditionObj.interaction === state.interaction
|
||||
);
|
||||
case 'microphone':
|
||||
return (
|
||||
(conditionObj.connected === undefined ||
|
||||
state.microphone?.connected === conditionObj.connected) &&
|
||||
(conditionObj.muted === undefined ||
|
||||
state.microphone?.muted === conditionObj.muted)
|
||||
);
|
||||
case 'key':
|
||||
return (
|
||||
!!state.keys &&
|
||||
conditionObj.key in state.keys &&
|
||||
(conditionObj.state ?? 'down') === state.keys[conditionObj.key].state &&
|
||||
(conditionObj.ctrl === undefined ||
|
||||
conditionObj.ctrl === !!state.keys[conditionObj.key].ctrl) &&
|
||||
(conditionObj.alt === undefined ||
|
||||
conditionObj.alt === !!state.keys[conditionObj.key].alt) &&
|
||||
(conditionObj.meta === undefined ||
|
||||
conditionObj.meta === !!state.keys[conditionObj.key].meta) &&
|
||||
(conditionObj.shift === undefined ||
|
||||
conditionObj.shift === !!state.keys[conditionObj.key].shift)
|
||||
);
|
||||
case 'user_agent':
|
||||
return (
|
||||
!!state.userAgent &&
|
||||
(!conditionObj.user_agent || conditionObj.user_agent === state.userAgent) &&
|
||||
(conditionObj.companion === undefined ||
|
||||
conditionObj.companion === isCompanionApp(state.userAgent)) &&
|
||||
(conditionObj.user_agent_re === undefined ||
|
||||
new RegExp(conditionObj.user_agent_re).test(state.userAgent))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected _createEpoch(): ConditionsManagerEpoch {
|
||||
return { manager: this };
|
||||
}
|
||||
|
||||
protected _triggerChange(oldState?: ConditionState): void {
|
||||
this._epoch = this._createEpoch();
|
||||
this._listeners.forEach((listener) => listener(this._state, oldState));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { ConditionsManager } from '../../conditions/conditions-manager.js';
|
||||
import { isConfigUpgradeable } from '../../config/management.js';
|
||||
import { setProfiles } from '../../config/profiles/index.js';
|
||||
import {
|
||||
@@ -9,9 +10,9 @@ import {
|
||||
} from '../../config/types.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import { getParseErrorPaths } from '../../utils/zod.js';
|
||||
import { getOverriddenConfig } from '../conditions-manager.js';
|
||||
import { InitializationAspect } from '../initialization-manager.js';
|
||||
import { CardConfigAPI } from '../types.js';
|
||||
import { getOverriddenConfig } from './get-overridden-config.js';
|
||||
import { setAutomationsFromConfig } from './load-automations.js';
|
||||
import { setKeyboardShortcutsFromConfig } from './load-keyboard-shortcuts.js';
|
||||
|
||||
@@ -26,6 +27,7 @@ export class ConfigManager {
|
||||
protected _overriddenConfig: AdvancedCameraCardConfig | null = null;
|
||||
protected _rawConfig: RawAdvancedCameraCardConfig | null = null;
|
||||
protected _cardWideConfig: CardWideConfig | null = null;
|
||||
protected _overridesConditionsManager: ConditionsManager | null = null;
|
||||
|
||||
constructor(api: CardConfigAPI) {
|
||||
this._api = api;
|
||||
@@ -85,8 +87,16 @@ export class ConfigManager {
|
||||
debug: config.debug,
|
||||
};
|
||||
|
||||
this._api.getConditionsManager().setConditionsFromConfig();
|
||||
this._api.getConditionsManager().setState({
|
||||
this._overridesConditionsManager?.destroy();
|
||||
this._overridesConditionsManager = this._config.overrides?.length
|
||||
? new ConditionsManager(
|
||||
this._config.overrides.map((override) => override.conditions).flat(),
|
||||
this._api.getConditionStateManager(),
|
||||
)
|
||||
: null;
|
||||
this._overridesConditionsManager?.addListener(() => this._processOverrideConfig());
|
||||
|
||||
this._api.getConditionStateManager().setState({
|
||||
view: undefined,
|
||||
displayMode: undefined,
|
||||
camera: undefined,
|
||||
@@ -102,31 +112,22 @@ export class ConfigManager {
|
||||
setKeyboardShortcutsFromConfig(this._api, this);
|
||||
setAutomationsFromConfig(this._api);
|
||||
|
||||
this.computeOverrideConfig();
|
||||
this._processOverrideConfig();
|
||||
|
||||
this._api.getCardElementManager().update();
|
||||
}
|
||||
|
||||
public computeOverrideConfig(): void {
|
||||
const conditionsManager = this._api.getConditionsManager();
|
||||
protected _processOverrideConfig(): void {
|
||||
/* istanbul ignore if: No (current) way to reach this code -- @preserve */
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
let overriddenConfig: AdvancedCameraCardConfig | null = null;
|
||||
try {
|
||||
overriddenConfig = getOverriddenConfig(conditionsManager, this._config, {
|
||||
configOverrides: this._config.overrides,
|
||||
schema: advancedCameraCardConfigSchema,
|
||||
});
|
||||
} catch (ev) {
|
||||
this._api.getMessageManager().setErrorIfHigherPriority(ev);
|
||||
return;
|
||||
}
|
||||
const overriddenConfig = this._getOverriddenConfig();
|
||||
|
||||
// Save on Lit re-rendering costs by only updating the configuration if it
|
||||
// actually changes.
|
||||
if (isEqual(overriddenConfig, this._overriddenConfig)) {
|
||||
if (!overriddenConfig || isEqual(overriddenConfig, this._overriddenConfig)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -154,14 +155,30 @@ export class ConfigManager {
|
||||
.uninitialize(InitializationAspect.MICROPHONE_CONNECT);
|
||||
}
|
||||
|
||||
/* async */ this._initializeBackground(previousConfig);
|
||||
/* async */ this._initializeBackgroundAndUpdate(previousConfig);
|
||||
}
|
||||
|
||||
protected _getOverriddenConfig(): AdvancedCameraCardConfig | null {
|
||||
if (!this._overridesConditionsManager || !this._config) {
|
||||
return this._config;
|
||||
}
|
||||
|
||||
try {
|
||||
return getOverriddenConfig(this._overridesConditionsManager, this._config, {
|
||||
configOverrides: this._config.overrides,
|
||||
schema: advancedCameraCardConfigSchema,
|
||||
});
|
||||
} catch (ev) {
|
||||
this._api.getMessageManager().setErrorIfHigherPriority(ev);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize config dependent items in the background. For items that the
|
||||
* card hard requires, use InitializationManager instead.
|
||||
*/
|
||||
protected async _initializeBackground(
|
||||
protected async _initializeBackgroundAndUpdate(
|
||||
previousConfig: AdvancedCameraCardConfig | null,
|
||||
): Promise<void> {
|
||||
await this._api.getDefaultManager().initializeIfNecessary(previousConfig);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { merge } from 'lodash-es';
|
||||
import { ZodType as ZodSchema } from 'zod';
|
||||
import { ConditionsManagerReadonlyInterface } from '../../conditions/types';
|
||||
import {
|
||||
copyConfig,
|
||||
deleteConfigValue,
|
||||
getConfigValue,
|
||||
setConfigValue,
|
||||
} from '../../config/management';
|
||||
import { Overrides, RawAdvancedCameraCardConfig } from '../../config/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { AdvancedCameraCardError } from '../../types';
|
||||
import { desparsifyArrays } from '../../utils/basic';
|
||||
|
||||
class OverrideConfigurationError extends AdvancedCameraCardError {}
|
||||
|
||||
export function getOverriddenConfig<RT extends RawAdvancedCameraCardConfig>(
|
||||
manager: ConditionsManagerReadonlyInterface,
|
||||
config: Readonly<RT>,
|
||||
options?: {
|
||||
configOverrides?: Readonly<Overrides>;
|
||||
schema?: ZodSchema;
|
||||
},
|
||||
): RT {
|
||||
if (!options?.configOverrides) {
|
||||
return config;
|
||||
}
|
||||
|
||||
let output = copyConfig(config);
|
||||
let overridden = false;
|
||||
for (const override of options.configOverrides) {
|
||||
if (manager.getEvaluation()?.result) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (!overridden) {
|
||||
// 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 (!parseResult.success) {
|
||||
throw new OverrideConfigurationError(
|
||||
localize('error.invalid_configuration_override'),
|
||||
[parseResult.error.errors, output],
|
||||
);
|
||||
}
|
||||
return parseResult.data;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
PTZKeyboardShortcutName,
|
||||
} from '../../config/keyboard-shortcuts';
|
||||
import { PTZAction } from '../../config/ptz';
|
||||
import { CardConfigLoaderAPI, TaggedAutomations } from '../types';
|
||||
import { createPTZMultiAction } from '../../utils/action';
|
||||
import { CardConfigLoaderAPI, TaggedAutomation } from '../types';
|
||||
|
||||
export const setKeyboardShortcutsFromConfig = (
|
||||
api: CardConfigLoaderAPI,
|
||||
@@ -47,12 +47,12 @@ const ptzKeyboardShortcutToPTZAction = (
|
||||
const convertKeyboardShortcutsToAutomations = (
|
||||
tag: unknown,
|
||||
shortcuts: KeyboardShortcuts,
|
||||
): TaggedAutomations => {
|
||||
): TaggedAutomation[] => {
|
||||
if (!shortcuts.enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const automations: TaggedAutomations = [];
|
||||
const automations: TaggedAutomation[] = [];
|
||||
|
||||
for (const name of [
|
||||
'ptz_down',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LovelaceCardEditor } from '@dermotduffy/custom-card-helpers';
|
||||
import { ReactiveController } from 'lit';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { ConditionStateManager } from '../conditions/state-manager';
|
||||
import { AdvancedCameraCardConfig } from '../config/types';
|
||||
import {
|
||||
createDeviceRegistryCache,
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
MenuToggleCallback,
|
||||
ScrollCallback,
|
||||
} from './card-element-manager';
|
||||
import { ConditionsManager, ConditionsManagerListener } from './conditions-manager';
|
||||
import { ConfigManager } from './config/config-manager';
|
||||
import { DefaultManager } from './default-manager';
|
||||
import { DownloadManager } from './download-manager';
|
||||
@@ -92,6 +92,8 @@ export class CardController
|
||||
CardViewAPI,
|
||||
ReactiveController
|
||||
{
|
||||
protected _conditionStateManager = new ConditionStateManager();
|
||||
|
||||
// These properties may be used in the construction of 'managers' (and should
|
||||
// be created first).
|
||||
protected _deviceRegistryManager = new DeviceRegistryManager(
|
||||
@@ -107,7 +109,6 @@ export class CardController
|
||||
protected _cameraManager = new CameraManager(this);
|
||||
protected _cameraURLManager = new CameraURLManager(this);
|
||||
protected _cardElementManager: CardElementManager;
|
||||
protected _conditionsManager: ConditionsManager;
|
||||
protected _configManager = new ConfigManager(this);
|
||||
protected _defaultManager = new DefaultManager(this);
|
||||
protected _downloadManager = new DownloadManager(this);
|
||||
@@ -131,11 +132,9 @@ export class CardController
|
||||
host: CardHTMLElement,
|
||||
scrollCallback: ScrollCallback,
|
||||
menuToggleCallback: MenuToggleCallback,
|
||||
conditionListener: ConditionsManagerListener,
|
||||
) {
|
||||
host.addController(this);
|
||||
|
||||
this._conditionsManager = new ConditionsManager(this, conditionListener);
|
||||
this._cardElementManager = new CardElementManager(
|
||||
this,
|
||||
host,
|
||||
@@ -171,8 +170,8 @@ export class CardController
|
||||
return this._cardElementManager;
|
||||
}
|
||||
|
||||
public getConditionsManager(): ConditionsManager {
|
||||
return this._conditionsManager;
|
||||
public getConditionStateManager(): ConditionStateManager {
|
||||
return this._conditionStateManager;
|
||||
}
|
||||
|
||||
public static async getConfigElement(): Promise<LovelaceCardEditor> {
|
||||
|
||||
@@ -32,7 +32,7 @@ export class ExpandManager {
|
||||
}
|
||||
|
||||
protected _setConditionState(): void {
|
||||
this._api.getConditionsManager()?.setState({
|
||||
this._api.getConditionStateManager()?.setState({
|
||||
expand: this._expanded,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export class FullscreenManager {
|
||||
};
|
||||
|
||||
protected _setConditionState(): void {
|
||||
this._api.getConditionsManager()?.setState({
|
||||
this._api.getConditionStateManager()?.setState({
|
||||
fullscreen: this.isInFullscreen(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ConditionStateChange } from '../../../conditions/types';
|
||||
import { WebkitHTMLVideoElement } from '../../../types';
|
||||
import { Timer } from '../../../utils/timer';
|
||||
import { ConditionState } from '../../conditions-manager';
|
||||
import { FullscreenProviderBase } from '../provider';
|
||||
import { FullscreenProvider } from '../types';
|
||||
|
||||
@@ -18,26 +18,23 @@ export class WebkitFullScreenProvider
|
||||
protected _playTimer = new Timer();
|
||||
|
||||
public connect(): void {
|
||||
this._api.getConditionsManager().addListener(this._conditionChangeHandler);
|
||||
this._api.getConditionStateManager().addListener(this._stateChangeHandler);
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
this._api.getConditionsManager().removeListener(this._conditionChangeHandler);
|
||||
this._api.getConditionStateManager().removeListener(this._stateChangeHandler);
|
||||
}
|
||||
|
||||
protected _conditionChangeHandler = (
|
||||
newState: ConditionState,
|
||||
oldState?: ConditionState,
|
||||
): void => {
|
||||
protected _stateChangeHandler = (change: ConditionStateChange): void => {
|
||||
if (
|
||||
newState.mediaLoadedInfo?.player?.getFullscreenElement() !==
|
||||
oldState?.mediaLoadedInfo?.player?.getFullscreenElement()
|
||||
change.old.mediaLoadedInfo?.player?.getFullscreenElement() !==
|
||||
change.new.mediaLoadedInfo?.player?.getFullscreenElement()
|
||||
) {
|
||||
const oldElement = oldState?.mediaLoadedInfo?.player?.getFullscreenElement();
|
||||
const oldElement = change.old.mediaLoadedInfo?.player?.getFullscreenElement();
|
||||
oldElement?.removeEventListener('webkitbeginfullscreen', this._handler);
|
||||
oldElement?.removeEventListener('webkitendfullscreen', this._endHandler);
|
||||
|
||||
const newElement = newState.mediaLoadedInfo?.player?.getFullscreenElement();
|
||||
const newElement = change.new.mediaLoadedInfo?.player?.getFullscreenElement();
|
||||
newElement?.addEventListener('webkitbeginfullscreen', this._handler);
|
||||
newElement?.addEventListener('webkitendfullscreen', this._endHandler);
|
||||
}
|
||||
|
||||
@@ -46,12 +46,10 @@ export class HASSManager {
|
||||
const oldHass = this._hass;
|
||||
this._hass = hass;
|
||||
|
||||
if (this._api.getConditionsManager().hasHAStateConditions()) {
|
||||
this._api.getConditionsManager().setState({
|
||||
state: this._hass.states,
|
||||
user: this._hass.user,
|
||||
});
|
||||
}
|
||||
this._api.getConditionStateManager().setState({
|
||||
state: this._hass.states,
|
||||
user: this._hass.user,
|
||||
});
|
||||
|
||||
// Theme may depend on HASS.
|
||||
this._api.getStyleManager().applyTheme();
|
||||
|
||||
@@ -33,7 +33,7 @@ export class InteractionManager {
|
||||
val,
|
||||
'interaction',
|
||||
);
|
||||
this._api.getConditionsManager().setState({ interaction: val });
|
||||
this._api.getConditionStateManager().setState({ interaction: val });
|
||||
}
|
||||
|
||||
protected _reportInteraction(): void {
|
||||
|
||||
@@ -54,6 +54,6 @@ export class KeyboardStateManager {
|
||||
};
|
||||
|
||||
protected _processStateChange(): void {
|
||||
this._api.getConditionsManager().setState({ keys: this._state });
|
||||
this._api.getConditionStateManager().setState({ keys: this._state });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export class MediaLoadedInfoManager {
|
||||
this._current = mediaLoadedInfo;
|
||||
this._lastKnown = mediaLoadedInfo;
|
||||
|
||||
this._api.getConditionsManager().setState({ mediaLoadedInfo: mediaLoadedInfo });
|
||||
this._api.getConditionStateManager().setState({ mediaLoadedInfo: mediaLoadedInfo });
|
||||
|
||||
// Fresh media information may change how the card is rendered.
|
||||
this._api.getStyleManager().setExpandedMode();
|
||||
@@ -47,7 +47,7 @@ export class MediaLoadedInfoManager {
|
||||
|
||||
public clear(): void {
|
||||
this._current = null;
|
||||
this._api.getConditionsManager().setState({ mediaLoadedInfo: null });
|
||||
this._api.getConditionStateManager().setState({ mediaLoadedInfo: null });
|
||||
}
|
||||
|
||||
public has(): boolean {
|
||||
|
||||
@@ -146,7 +146,7 @@ export class MicrophoneManager {
|
||||
muted: this.isMuted(),
|
||||
forbidden: this.isForbidden(),
|
||||
};
|
||||
this._api.getConditionsManager().setState({
|
||||
this._api.getConditionStateManager().setState({
|
||||
microphone: this._state,
|
||||
});
|
||||
this._api.getCardElementManager().update();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import { CameraEvent } from '../camera-manager/types';
|
||||
@@ -132,13 +131,9 @@ export class TriggersManager {
|
||||
const triggeredCameraIDs = new Set(this._triggeredCameras.keys());
|
||||
const triggeredState = triggeredCameraIDs.size ? triggeredCameraIDs : undefined;
|
||||
|
||||
if (
|
||||
!isEqual(triggeredState, this._api.getConditionsManager().getState().triggered)
|
||||
) {
|
||||
this._api.getConditionsManager().setState({
|
||||
triggered: triggeredState,
|
||||
});
|
||||
}
|
||||
this._api.getConditionStateManager().setState({
|
||||
triggered: triggeredState,
|
||||
});
|
||||
}
|
||||
|
||||
protected async _untriggerAction(cameraID: string): Promise<void> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CameraManager } from '../camera-manager/manager';
|
||||
import type { ConditionStateManager } from '../conditions/state-manager';
|
||||
import type { Automation } from '../config/types';
|
||||
import type { EntityRegistryManager } from '../utils/ha/registry/entity';
|
||||
import type { ResolvedMediaCache } from '../utils/ha/resolved-media';
|
||||
@@ -6,7 +7,6 @@ import type { ActionsManager } from './actions/actions-manager';
|
||||
import type { AutomationsManager } from './automations-manager';
|
||||
import type { CameraURLManager } from './camera-url-manager';
|
||||
import type { CardElementManager } from './card-element-manager';
|
||||
import type { ConditionsManager } from './conditions-manager';
|
||||
import type { ConfigManager } from './config/config-manager';
|
||||
import type { DefaultManager } from './default-manager';
|
||||
import type { DownloadManager } from './download-manager';
|
||||
@@ -38,7 +38,7 @@ export interface CardActionsAPI {
|
||||
getCameraManager(): CameraManager;
|
||||
getCameraURLManager(): CameraURLManager;
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionsManager(): ConditionsManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getDownloadManager(): DownloadManager;
|
||||
getExpandManager(): ExpandManager;
|
||||
@@ -57,7 +57,7 @@ export type CardActionsManagerAPI = CardActionsAPI;
|
||||
export interface CardAutomationsAPI {
|
||||
getActionsManager(): ActionsManager;
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionsManager(): ConditionsManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getHASSManager(): HASSManager;
|
||||
getInitializationManager(): InitializationManager;
|
||||
getMessageManager(): MessageManager;
|
||||
@@ -87,7 +87,7 @@ export interface CardConfigAPI {
|
||||
getAutomationsManager(): AutomationsManager;
|
||||
getCameraManager(): CameraManager;
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionsManager(): ConditionsManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getDefaultManager(): DefaultManager;
|
||||
getInitializationManager(): InitializationManager;
|
||||
@@ -125,7 +125,7 @@ export interface CardDownloadAPI {
|
||||
export interface CardElementAPI {
|
||||
getActionsManager(): ActionsManager;
|
||||
getCameraManager(): CameraManager;
|
||||
getConditionsManager(): ConditionsManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getDefaultManager(): DefaultManager;
|
||||
getExpandManager(): ExpandManager;
|
||||
@@ -142,13 +142,13 @@ export interface CardElementAPI {
|
||||
|
||||
export interface CardExpandAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionsManager(): ConditionsManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getFullscreenManager(): FullscreenManager;
|
||||
}
|
||||
|
||||
export interface CardFullscreenAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionsManager(): ConditionsManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getExpandManager(): ExpandManager;
|
||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||
getMediaPlayerManager(): MediaPlayerManager;
|
||||
@@ -157,7 +157,7 @@ export interface CardFullscreenAPI {
|
||||
export interface CardHASSAPI {
|
||||
getCameraManager(): CameraManager;
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionsManager(): ConditionsManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getDefaultManager(): DefaultManager;
|
||||
getInteractionManager(): InteractionManager;
|
||||
@@ -189,7 +189,7 @@ export interface CardInitializerAPI {
|
||||
|
||||
export interface CardInteractionAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionsManager(): ConditionsManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getStyleManager(): StyleManager;
|
||||
getTriggersManager(): TriggersManager;
|
||||
@@ -198,13 +198,13 @@ export interface CardInteractionAPI {
|
||||
|
||||
export interface CardKeyboardStateAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionsManager(): ConditionsManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
}
|
||||
|
||||
export interface CardMediaLoadedAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionsManager(): ConditionsManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getFullscreenManager(): FullscreenManager;
|
||||
getStyleManager(): StyleManager;
|
||||
@@ -221,13 +221,13 @@ export interface CardMediaPlayerAPI {
|
||||
|
||||
export interface CardMessageAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionsManager(): ConditionsManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||
}
|
||||
|
||||
export interface CardMicrophoneAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionsManager(): ConditionsManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
}
|
||||
|
||||
@@ -254,7 +254,7 @@ export interface CardStyleAPI {
|
||||
|
||||
export interface CardTriggersAPI {
|
||||
getCameraManager(): CameraManager;
|
||||
getConditionsManager(): ConditionsManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getInteractionManager(): InteractionManager;
|
||||
@@ -264,7 +264,7 @@ export interface CardTriggersAPI {
|
||||
export interface CardViewAPI {
|
||||
getCameraManager(): CameraManager;
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionsManager(): ConditionsManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getHASSManager(): HASSManager;
|
||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||
@@ -295,7 +295,6 @@ export interface MicrophoneState {
|
||||
forbidden: boolean;
|
||||
}
|
||||
|
||||
interface TaggedAutomation extends Automation {
|
||||
export interface TaggedAutomation extends Automation {
|
||||
tag?: unknown;
|
||||
}
|
||||
export type TaggedAutomations = TaggedAutomation[];
|
||||
|
||||
@@ -301,7 +301,7 @@ export class ViewManager implements ViewManagerInterface {
|
||||
this._api.getMessageManager().reset();
|
||||
this._api.getStyleManager().setExpandedMode();
|
||||
|
||||
this._api.getConditionsManager()?.setState({
|
||||
this._api.getConditionStateManager()?.setState({
|
||||
view: view?.view,
|
||||
camera: view?.camera,
|
||||
displayMode: view?.displayMode ?? undefined,
|
||||
|
||||
Reference in New Issue
Block a user