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:
Dermot Duffy
2025-02-11 20:07:55 -08:00
committed by GitHub
parent a92074bc8d
commit 8d3cf07b43
51 changed files with 2443 additions and 2118 deletions
+26 -21
View File
@@ -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);
}
}
+1 -1
View File
@@ -127,7 +127,7 @@ export class CardElementManager {
this._api.getQueryStringManager().requestExecution,
);
this._api.getConditionsManager()?.setState({
this._api.getConditionStateManager()?.setState({
userAgent: navigator.userAgent,
});
-385
View File
@@ -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));
}
}
+36 -19
View File
@@ -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',
+5 -6
View File
@@ -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> {
+1 -1
View File
@@ -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(),
});
}
+8 -11
View File
@@ -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);
}
+4 -6
View File
@@ -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();
+1 -1
View File
@@ -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 });
}
}
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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();
+3 -8
View File
@@ -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> {
+16 -17
View File
@@ -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[];
+1 -1
View File
@@ -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,
+5 -32
View File
@@ -7,7 +7,6 @@ import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { styleMap } from 'lit/directives/style-map.js';
import 'web-dialog';
import { actionHandler } from './action-handler-directive.js';
import { ConditionsEvaluateRequestEvent } from './card-controller/conditions-manager.js';
import { CardController } from './card-controller/controller';
import { MenuButtonController } from './components-lib/menu-button-controller';
import './components/elements.js';
@@ -23,6 +22,7 @@ import './components/status-bar';
import './components/thumbnail-carousel.js';
import './components/views.js';
import { AdvancedCameraCardViews } from './components/views.js';
import { ConditionStateManagerGetEvent } from './conditions/state-manager-via-event.js';
import {
AdvancedCameraCardConfig,
MenuItem,
@@ -95,7 +95,6 @@ class AdvancedCameraCard extends LitElement {
// diagnostics starting at the top).
() => this._refMain.value?.scroll({ top: 0 }),
() => this._refMenu.value?.toggleMenu(),
this._requestUpdateForComponentsThatUseConditions.bind(this),
);
protected _menuButtonController = new MenuButtonController();
@@ -143,21 +142,6 @@ class AdvancedCameraCard extends LitElement {
return CardController.getStubConfig(entities);
}
protected _requestUpdateForComponentsThatUseConditions(): void {
// Update the components that need to know about condition changes. Trigger
// updates directly on them to them to avoid the performance hit of a entire
// card re-render (esp. when using card-mod).
// https://github.com/dermotduffy/advanced-camera-card/issues/678
if (this._refViews.value) {
this._refViews.value.conditionsManagerEpoch =
this._controller.getConditionsManager().getEpoch() ?? undefined;
}
if (this._refElements.value) {
this._refElements.value.conditionsManagerEpoch =
this._controller.getConditionsManager().getEpoch() ?? undefined;
}
}
public setConfig(config: RawAdvancedCameraCardConfig): void {
this._controller.getConfigManager().setConfig(config);
}
@@ -390,16 +374,10 @@ class AdvancedCameraCard extends LitElement {
.viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
.cameraManager=${cameraManager}
.resolvedMediaCache=${this._controller.getResolvedMediaCache()}
.nonOverriddenConfig=${this._controller
.getConfigManager()
.getNonOverriddenConfig()}
.overriddenConfig=${this._controller.getConfigManager().getConfig()}
.config=${this._controller.getConfigManager().getConfig()}
.cardWideConfig=${this._controller.getConfigManager().getCardWideConfig()}
.rawConfig=${this._controller.getConfigManager().getRawConfig()}
.configManager=${this._controller.getConfigManager()}
.conditionsManagerEpoch=${this._controller
.getConditionsManager()
?.getEpoch()}
.hide=${!!this._controller.getMessageManager().hasMessage()}
.microphoneState=${this._controller.getMicrophoneManager().getState()}
.triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
@@ -421,9 +399,6 @@ class AdvancedCameraCard extends LitElement {
${ref(this._refElements)}
.hass=${this._hass}
.elements=${this._config?.elements}
.conditionsManagerEpoch=${this._controller
.getConditionsManager()
?.getEpoch()}
@advanced-camera-card:menu:add=${(ev: CustomEvent<MenuItem>) => {
this._menuButtonController.addDynamicMenuButton(ev.detail);
this.requestUpdate();
@@ -446,12 +421,10 @@ class AdvancedCameraCard extends LitElement {
.getStatusBarItemManager()
.removeDynamicStatusBarItem(ev.detail);
}}
@advanced-camera-card:conditions:evaluate=${(
ev: ConditionsEvaluateRequestEvent,
@advanced-camera-card:condition-state-manager:get=${(
ev: ConditionStateManagerGetEvent,
) => {
ev.evaluation = this._controller
.getConditionsManager()
?.evaluateConditions(ev.conditions);
ev.conditionStateManager = this._controller.getConditionStateManager();
}}
>
</advanced-camera-card-elements>`
+24 -16
View File
@@ -8,10 +8,8 @@ import {
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import {
ConditionsManagerEpoch,
evaluateConditionViaEvent,
} from '../card-controller/conditions-manager.js';
import { ConditionsManager } from '../conditions/conditions-manager.js';
import { getConditionStateManagerViaEvent } from '../conditions/state-manager-via-event.js';
import { dispatchAdvancedCameraCardErrorEvent } from '../components-lib/message/dispatch.js';
import {
AdvancedCameraCardConditional,
@@ -76,13 +74,6 @@ export class AdvancedCameraCardElementsCore extends LitElement {
@property({ attribute: false })
public elements?: PictureElements;
/**
* Need to ensure card re-renders when conditions change, hence having it as a
* property even though it is not currently directly used by this class.
*/
@property({ attribute: false })
public conditionsManagerEpoch?: ConditionsManagerEpoch;
protected _root: HuiConditionalElement | null = null;
@property({ attribute: false })
@@ -162,9 +153,6 @@ export class AdvancedCameraCardElements extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public conditionsManagerEpoch?: ConditionsManagerEpoch;
@property({ attribute: false })
public elements: PictureElements;
@@ -248,7 +236,6 @@ export class AdvancedCameraCardElements extends LitElement {
protected render(): TemplateResult {
return html`<advanced-camera-card-elements-core
.hass=${this.hass}
.conditionsManagerEpoch=${this.conditionsManagerEpoch}
.elements=${this.elements}
>
</advanced-camera-card-elements-core>`;
@@ -267,6 +254,7 @@ export class AdvancedCameraCardElements extends LitElement {
@customElement('advanced-camera-card-conditional')
export class AdvancedCameraCardElementsConditional extends LitElement {
protected _config?: AdvancedCameraCardConditional;
protected _conditionManager: ConditionsManager | null = null;
// A note on hass as an update mechanism:
//
@@ -283,6 +271,7 @@ export class AdvancedCameraCardElementsConditional extends LitElement {
*/
public setConfig(config: AdvancedCameraCardConditional): void {
this._config = config;
this._createConditionManager();
}
/**
@@ -303,13 +292,32 @@ export class AdvancedCameraCardElementsConditional extends LitElement {
// this is a transparent 'conditional' element (just like the stock HA
// 'conditional' element), it should not have positioning.
this.className = '';
this._createConditionManager();
}
disconnectedCallback(): void {
this._conditionManager?.destroy();
}
protected _createConditionManager(): void {
const conditionStateManager = getConditionStateManagerViaEvent(this);
if (!this._config || !conditionStateManager) {
return;
}
this._conditionManager?.destroy();
this._conditionManager = new ConditionsManager(
this._config.conditions,
conditionStateManager,
);
this._conditionManager.addListener(() => this.requestUpdate());
}
/**
* Render the card.
*/
protected render(): TemplateResult | void {
if (evaluateConditionViaEvent(this, this._config?.conditions)) {
if (this._conditionManager?.getEvaluation()?.result) {
return html` <advanced-camera-card-elements-core
.hass=${this.hass}
.elements=${this._config?.elements}
+24 -74
View File
@@ -11,14 +11,9 @@ import { guard } from 'lit/directives/guard.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { CameraManagerCameraMetadata } from '../../camera-manager/types.js';
import {
ConditionsManagerEpoch,
getOverriddenConfig,
} from '../../card-controller/conditions-manager.js';
import { MicrophoneState } from '../../card-controller/types.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { MediaActionsController } from '../../components-lib/media-actions-controller.js';
import { dispatchAdvancedCameraCardErrorEvent } from '../../components-lib/message/dispatch.js';
import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js';
import {
@@ -26,14 +21,11 @@ import {
CardWideConfig,
configDefaults,
LiveConfig,
liveConfigAbsoluteRootSchema,
Overrides,
TransitionEffect,
} from '../../config/types.js';
import liveCarouselStyle from '../../scss/live-carousel.scss';
import { ExtendedHomeAssistant } from '../../types.js';
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
import { contentsChanged } from '../../utils/basic.js';
import { CarouselSelected } from '../../utils/embla/carousel-controller.js';
import { AutoLazyLoad } from '../../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js';
import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
@@ -70,16 +62,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public nonOverriddenLiveConfig?: LiveConfig;
@property({ attribute: false })
public overriddenLiveConfig?: LiveConfig;
@property({ attribute: false, hasChanged: contentsChanged })
public overrides?: Overrides;
@property({ attribute: false })
public conditionsManagerEpoch?: ConditionsManagerEpoch;
public liveConfig?: LiveConfig;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@@ -116,10 +99,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
}
protected _getTransitionEffect(): TransitionEffect {
return (
this.overriddenLiveConfig?.transition_effect ??
configDefaults.live.transition_effect
);
return this.liveConfig?.transition_effect ?? configDefaults.live.transition_effect;
}
protected _getSelectedCameraIndex(): number {
@@ -138,29 +118,25 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
}
protected willUpdate(changedProps: PropertyValues): void {
if (
changedProps.has('microphoneState') ||
changedProps.has('overriddenLiveConfig')
) {
if (changedProps.has('microphoneState') || changedProps.has('liveConfig')) {
this._mediaActionsController.setOptions({
playerSelector: ADVANCED_CAMERA_CARD_LIVE_PROVIDER,
...(this.overriddenLiveConfig?.auto_play && {
autoPlayConditions: this.overriddenLiveConfig.auto_play,
...(this.liveConfig?.auto_play && {
autoPlayConditions: this.liveConfig.auto_play,
}),
...(this.overriddenLiveConfig?.auto_pause && {
autoPauseConditions: this.overriddenLiveConfig.auto_pause,
...(this.liveConfig?.auto_pause && {
autoPauseConditions: this.liveConfig.auto_pause,
}),
...(this.overriddenLiveConfig?.auto_mute && {
autoMuteConditions: this.overriddenLiveConfig.auto_mute,
...(this.liveConfig?.auto_mute && {
autoMuteConditions: this.liveConfig.auto_mute,
}),
...(this.overriddenLiveConfig?.auto_unmute && {
autoUnmuteConditions: this.overriddenLiveConfig.auto_unmute,
...(this.liveConfig?.auto_unmute && {
autoUnmuteConditions: this.liveConfig.auto_unmute,
}),
...((this.overriddenLiveConfig?.auto_unmute ||
this.overriddenLiveConfig?.auto_mute) && {
...((this.liveConfig?.auto_unmute || this.liveConfig?.auto_mute) && {
microphoneState: this.microphoneState,
microphoneMuteSeconds:
this.overriddenLiveConfig.microphone.mute_after_microphone_mute_seconds,
this.liveConfig.microphone.mute_after_microphone_mute_seconds,
}),
});
}
@@ -169,11 +145,11 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
protected _getPlugins(): EmblaCarouselPlugins {
return [
AutoLazyLoad({
...(this.overriddenLiveConfig?.lazy_load && {
...(this.liveConfig?.lazy_load && {
lazyLoadCallback: (index, slide) =>
this._lazyloadOrUnloadSlide('load', index, slide),
}),
lazyUnloadConditions: this.overriddenLiveConfig?.lazy_unload,
lazyUnloadConditions: this.liveConfig?.lazy_unload,
lazyUnloadCallback: (index, slide) =>
this._lazyloadOrUnloadSlide('unload', index, slide),
}),
@@ -191,7 +167,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
*/
protected _getLazyLoadCount(): number | null {
// Defaults to fully-lazy loading.
return this.overriddenLiveConfig?.lazy_load === false ? null : 0;
return this.liveConfig?.lazy_load === false ? null : 0;
}
protected _getSlides(): [TemplateResult[], Record<string, number>] {
@@ -265,43 +241,17 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
cameraID: string,
cameraConfig: CameraConfig,
): TemplateResult | void {
if (
!this.overriddenLiveConfig ||
!this.nonOverriddenLiveConfig ||
!this.hass ||
!this.cameraManager ||
!this.conditionsManagerEpoch
) {
if (!this.liveConfig || !this.hass || !this.cameraManager) {
return;
}
let liveConfig: LiveConfig | null = null;
try {
// The condition controller object contains the currently live camera, which
// (in the carousel for example) is not necessarily the live camera *this*
// <advanced-camera-card-live-provider> is rendering right now, so we provide a
// stateOverride to evaluate the condition in that context.
liveConfig = getOverriddenConfig(
this.conditionsManagerEpoch.manager,
{ live: this.nonOverriddenLiveConfig },
{
configOverrides: this.overrides,
stateOverrides: { camera: cameraID },
schema: liveConfigAbsoluteRootSchema,
},
).live;
} catch (ev) {
return dispatchAdvancedCameraCardErrorEvent(this, ev);
}
const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID);
const view = this.viewManagerEpoch?.manager.getView();
return html`
<div class="embla__slide">
<advanced-camera-card-live-provider
?load=${!liveConfig.lazy_load}
?load=${!this.liveConfig.lazy_load}
.microphoneState=${view?.camera === cameraID
? this.microphoneState
: undefined}
@@ -311,7 +261,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
() => this.cameraManager?.getCameraEndpoints(cameraID) ?? undefined,
)}
.label=${cameraMetadata?.title ?? ''}
.liveConfig=${liveConfig}
.liveConfig=${this.liveConfig}
.hass=${this.hass}
.cardWideConfig=${this.cardWideConfig}
.zoomSettings=${view?.context?.zoom?.[cameraID]?.requested}
@@ -385,7 +335,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
slot=${side}
.hass=${this.hass}
.side=${side}
.controlConfig=${this.overriddenLiveConfig?.controls.next_previous}
.controlConfig=${this.liveConfig?.controls.next_previous}
.label=${neighbor?.metadata?.title ?? ''}
.icon=${neighbor?.metadata?.icon}
?disabled=${!neighbor}
@@ -399,7 +349,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
protected render(): TemplateResult | void {
const view = this.viewManagerEpoch?.manager.getView();
if (!this.overriddenLiveConfig || !this.hass || !view || !this.cameraManager) {
if (!this.liveConfig || !this.hass || !view || !this.cameraManager) {
return;
}
@@ -427,9 +377,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
<advanced-camera-card-carousel
${ref(this._refCarousel)}
.loop=${hasMultipleCameras}
.dragEnabled=${hasMultipleCameras && this.overriddenLiveConfig?.draggable}
.dragEnabled=${hasMultipleCameras && this.liveConfig?.draggable}
.plugins=${guard(
[this.cameraManager, this.overriddenLiveConfig],
[this.cameraManager, this.liveConfig],
this._getPlugins.bind(this),
)}
.selected=${this._getSelectedCameraIndex()}
@@ -449,7 +399,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
${this._renderNextPrevious('right', neighbors)}
</advanced-camera-card-carousel>
<advanced-camera-card-ptz
.config=${this.overriddenLiveConfig.controls.ptz}
.config=${this.liveConfig.controls.ptz}
.cameraManager=${this.cameraManager}
.cameraID=${getStreamCameraID(view, this.viewFilterCameraID)}
.forceVisibility=${forcePTZVisibility}
+4 -21
View File
@@ -9,14 +9,12 @@ import {
import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { ConditionsManagerEpoch } from '../../card-controller/conditions-manager.js';
import { MicrophoneState } from '../../card-controller/types.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { MediaGridSelected } from '../../components-lib/media-grid-controller.js';
import { CardWideConfig, LiveConfig, Overrides } from '../../config/types.js';
import { CardWideConfig, LiveConfig } from '../../config/types.js';
import liveGridStyle from '../../scss/live-grid.scss';
import { ExtendedHomeAssistant } from '../../types.js';
import { contentsChanged } from '../../utils/basic.js';
import './carousel.js';
@customElement('advanced-camera-card-live-grid')
@@ -28,16 +26,7 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public nonOverriddenLiveConfig?: LiveConfig;
@property({ attribute: false })
public overriddenLiveConfig?: LiveConfig;
@property({ attribute: false, hasChanged: contentsChanged })
public overrides?: Overrides;
@property({ attribute: false })
public conditionsManagerEpoch?: ConditionsManagerEpoch;
public liveConfig?: LiveConfig;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@@ -61,10 +50,7 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
.hass=${this.hass}
.viewManagerEpoch=${this.viewManagerEpoch}
.viewFilterCameraID=${cameraID}
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
.overriddenLiveConfig=${this.overriddenLiveConfig}
.conditionsManagerEpoch=${this.conditionsManagerEpoch}
.overrides=${this.overrides}
.liveConfig=${this.liveConfig}
.cardWideConfig=${this.cardWideConfig}
.cameraManager=${this.cameraManager}
.microphoneState=${this.microphoneState}
@@ -101,9 +87,6 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
}
protected render(): TemplateResult | void {
if (!this.conditionsManagerEpoch || !this.nonOverriddenLiveConfig) {
return;
}
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
if (!cameraIDs?.size || !this._needsGrid()) {
return this._renderCarousel();
@@ -112,7 +95,7 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
return html`
<advanced-camera-card-media-grid
.selected=${this.viewManagerEpoch?.manager.getView()?.camera}
.displayConfig=${this.overriddenLiveConfig?.display}
.displayConfig=${this.liveConfig?.display}
@advanced-camera-card:media-grid:selected=${(
ev: CustomEvent<MediaGridSelected>,
) => this._gridSelectCamera(ev.detail.selected)}
+4 -18
View File
@@ -1,21 +1,16 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { CameraManager } from '../../camera-manager/manager.js';
import { ConditionsManagerEpoch } from '../../card-controller/conditions-manager.js';
import { MicrophoneState } from '../../card-controller/types.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { LiveController } from '../../components-lib/live/live-controller.js';
import { CardWideConfig, LiveConfig, Overrides } from '../../config/types.js';
import { CardWideConfig, LiveConfig } from '../../config/types.js';
import basicBlockStyle from '../../scss/basic-block.scss';
import { ExtendedHomeAssistant } from '../../types.js';
import { contentsChanged } from '../../utils/basic.js';
import './grid.js';
@customElement('advanced-camera-card-live')
export class AdvancedCameraCardLive extends LitElement {
@property({ attribute: false })
public conditionsManagerEpoch?: ConditionsManagerEpoch;
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
@@ -23,13 +18,7 @@ export class AdvancedCameraCardLive extends LitElement {
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public nonOverriddenLiveConfig?: LiveConfig;
@property({ attribute: false })
public overriddenLiveConfig?: LiveConfig;
@property({ attribute: false, hasChanged: contentsChanged })
public overrides?: Overrides;
public liveConfig?: LiveConfig;
@property({ attribute: false })
public cameraManager?: CameraManager;
@@ -46,7 +35,7 @@ export class AdvancedCameraCardLive extends LitElement {
protected _controller = new LiveController(this);
protected render(): TemplateResult | void {
if (!this.hass || !this.nonOverriddenLiveConfig || !this.cameraManager) {
if (!this.hass || !this.cameraManager) {
return;
}
@@ -60,11 +49,8 @@ export class AdvancedCameraCardLive extends LitElement {
<advanced-camera-card-live-grid
.hass=${this.hass}
.viewManagerEpoch=${this.viewManagerEpoch}
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
.overriddenLiveConfig=${this.overriddenLiveConfig}
.liveConfig=${this.liveConfig}
.inBackground=${this._controller.isInBackground()}
.conditionsManagerEpoch=${this.conditionsManagerEpoch}
.overrides=${this.overrides}
.cardWideConfig=${this.cardWideConfig}
.cameraManager=${this.cameraManager}
.microphoneState=${this.microphoneState}
+2 -1
View File
@@ -25,8 +25,8 @@ import { STREAM_TROUBLESHOOTING_URL } from '../../const.js';
import { localize } from '../../localize/localize.js';
import liveProviderStyle from '../../scss/live-provider.scss';
import {
ExtendedHomeAssistant,
AdvancedCameraCardMediaPlayer,
ExtendedHomeAssistant,
FullscreenElement,
} from '../../types.js';
import { aspectRatioToString } from '../../utils/basic.js';
@@ -210,6 +210,7 @@ export class AdvancedCameraCardLiveProvider
dispatchMediaUnloadedEvent(this);
}
}
if (changedProps.has('liveConfig')) {
if (this.liveConfig?.show_image_during_load) {
this._importPromises.push(import('./providers/image.js'));
+15 -53
View File
@@ -9,7 +9,6 @@ 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 } from '../card-controller/conditions-manager.js';
import { MicrophoneState } from '../card-controller/types.js';
import { ViewManagerEpoch } from '../card-controller/view/types.js';
import {
@@ -39,10 +38,7 @@ export class AdvancedCameraCardViews extends LitElement {
public cameraManager?: CameraManager;
@property({ attribute: false })
public nonOverriddenConfig?: AdvancedCameraCardConfig;
@property({ attribute: false })
public overriddenConfig?: AdvancedCameraCardConfig;
public config?: AdvancedCameraCardConfig;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@@ -53,9 +49,6 @@ export class AdvancedCameraCardViews extends LitElement {
@property({ attribute: false })
public resolvedMediaCache?: ResolvedMediaCache;
@property({ attribute: false })
public conditionsManagerEpoch?: ConditionsManagerEpoch;
@property({ attribute: false })
public hide?: boolean;
@@ -94,31 +87,12 @@ export class AdvancedCameraCardViews extends LitElement {
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected shouldUpdate(_: PropertyValues): boolean {
// Future: Updates to `hass` and `conditionState` here will be frequent.
// Throttling here may be necessary if users report performance degradation
// > v5.0.0-beta1 .
//
// These updates are necessary in these cases:
// - conditionState: Required to let `advanced-camera-card-live` calculate its own
// overrides.
// - hass: Required for anything that needs to sign URLs. Of note is
// anything that renders an image (e.g. a thumbnail -- almost everything,
// or the main `advanced-camera-card-image` view).
//
// It should instead be possible to pass conditionState to live only (every
// update required), and pass hass only once / 5 minutes (see
// HASS_REJECTION_CUTOFF_MS).
return true;
}
protected _shouldLivePreload(): boolean {
const view = this.viewManagerEpoch?.manager.getView();
return (
// Special case: Never preload for diagnostics -- we want that to be as
// minimal as possible.
!!this.overriddenConfig?.live.preload && !view?.is('diagnostics')
!!this.config?.live.preload && !view?.is('diagnostics')
);
}
@@ -127,12 +101,7 @@ export class AdvancedCameraCardViews extends LitElement {
// overall views pane to render in ~almost all cases (e.g. for a camera
// initialization error to display, `view` and `cameraConfig` may both be
// undefined, but we still want to render).
if (
!this.hass ||
!this.overriddenConfig ||
!this.nonOverriddenConfig ||
!this.cardWideConfig
) {
if (!this.hass || !this.config || !this.cardWideConfig) {
return html``;
}
@@ -148,17 +117,17 @@ export class AdvancedCameraCardViews extends LitElement {
};
const thumbnailConfig = view?.is('live')
? this.overriddenConfig.live.controls.thumbnails
? this.config.live.controls.thumbnails
: view?.isViewerView()
? this.overriddenConfig.media_viewer.controls.thumbnails
? this.config.media_viewer.controls.thumbnails
: view?.is('timeline')
? this.overriddenConfig.timeline.controls.thumbnails
? this.config.timeline.controls.thumbnails
: undefined;
const miniTimelineConfig = view?.is('live')
? this.overriddenConfig.live.controls.timeline
? this.config.live.controls.timeline
: view?.isViewerView()
? this.overriddenConfig.media_viewer.controls.timeline
? this.config.media_viewer.controls.timeline
: undefined;
const cameraConfig = view
@@ -176,7 +145,7 @@ export class AdvancedCameraCardViews extends LitElement {
>
${!this.hide && view?.is('image') && cameraConfig
? html` <advanced-camera-card-image
.imageConfig=${this.overriddenConfig.image}
.imageConfig=${this.config.image}
.viewManagerEpoch=${this.viewManagerEpoch}
.hass=${this.hass}
.cameraConfig=${cameraConfig}
@@ -188,7 +157,7 @@ export class AdvancedCameraCardViews extends LitElement {
? html` <advanced-camera-card-gallery
.hass=${this.hass}
.viewManagerEpoch=${this.viewManagerEpoch}
.galleryConfig=${this.overriddenConfig.media_gallery}
.galleryConfig=${this.config.media_gallery}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
@@ -199,7 +168,7 @@ export class AdvancedCameraCardViews extends LitElement {
<advanced-camera-card-viewer
.hass=${this.hass}
.viewManagerEpoch=${this.viewManagerEpoch}
.viewerConfig=${this.overriddenConfig.media_viewer}
.viewerConfig=${this.config.media_viewer}
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
@@ -211,7 +180,7 @@ export class AdvancedCameraCardViews extends LitElement {
? html` <advanced-camera-card-timeline
.hass=${this.hass}
.viewManagerEpoch=${this.viewManagerEpoch}
.timelineConfig=${this.overriddenConfig.timeline}
.timelineConfig=${this.config.timeline}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
@@ -226,21 +195,14 @@ export class AdvancedCameraCardViews extends LitElement {
</advanced-camera-card-diagnostics>`
: ``}
${
// Note: Subtle difference in condition below vs the other views in order
// to always render the live view for live.preload mode.
// Note: <advanced-camera-card-live> uses nonOverriddenConfig rather than the
// overriden config as it does it's own overriding as part of the camera
// carousel.
// Note: Subtle difference in condition below vs the other views in
// order to always render the live view for live.preload mode.
this._shouldLivePreload() || (!this.hide && view?.is('live'))
? html`
<advanced-camera-card-live
.hass=${this.hass}
.viewManagerEpoch=${this.viewManagerEpoch}
.nonOverriddenLiveConfig=${this.nonOverriddenConfig.live}
.overriddenLiveConfig=${this.overriddenConfig.live}
.conditionsManagerEpoch=${this.conditionsManagerEpoch}
.overrides=${this.overriddenConfig.overrides}
.liveConfig=${this.config.live}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
.microphoneState=${this.microphoneState}
+286
View File
@@ -0,0 +1,286 @@
import { isEqual } from 'lodash-es';
import { AdvancedCameraCardCondition } from '../config/types';
import { isCompanionApp } from '../utils/companion';
import {
ConditionsEvaluationData,
ConditionsEvaluationResult,
ConditionsListener,
ConditionsManagerReadonlyInterface,
ConditionState,
ConditionStateChange,
ConditionStateManagerReadonlyInterface,
} from './types';
/**
* A class to evaluate an array of conditions, and notify listeners when the
* evaluation changes (a change is either the result changing, or the data
* associated with a result).
*/
export class ConditionsManager implements ConditionsManagerReadonlyInterface {
protected _conditions: AdvancedCameraCardCondition[];
protected _stateManager: ConditionStateManagerReadonlyInterface | null;
protected _listeners: ConditionsListener[] = [];
protected _mediaQueries: MediaQueryList[] = [];
protected _hasHAStateConditions = false;
protected _evaluation: ConditionsEvaluationResult = { result: false };
constructor(
conditions: AdvancedCameraCardCondition[],
stateManager?: ConditionStateManagerReadonlyInterface | null,
) {
this._conditions = conditions;
this._hasHAStateConditions = conditions.some(
(condition) =>
!condition.condition ||
['state', 'numeric_state', 'user'].includes(condition.condition),
);
conditions.forEach((condition) => {
if (condition.condition === 'screen') {
const mql = window.matchMedia(condition.media_query);
mql.addEventListener('change', this._mediaQueryHandler);
this._mediaQueries.push(mql);
}
});
this._stateManager = stateManager ?? null;
// Do an initial condition evaluation, but without calling listeners.
this._evaluate({ callListeners: false });
this._stateManager?.addListener(this._stateManagerHandler);
}
public destroy(): void {
this._stateManager?.removeListener(this._stateManagerHandler);
this._listeners.forEach((l) => this.removeListener(l));
this._mediaQueries.forEach((mql) =>
mql.removeEventListener('change', this._mediaQueryHandler),
);
this._mediaQueries = [];
this._conditions = [];
}
public addListener(listener: ConditionsListener): void {
if (!this._listeners.includes(listener)) {
this._listeners.push(listener);
}
}
public removeListener(listener: ConditionsListener): void {
this._listeners = this._listeners.filter((l) => l !== listener);
}
public getEvaluation(): ConditionsEvaluationResult {
return this._evaluation;
}
protected _mediaQueryHandler = () => this._evaluate();
protected _stateManagerHandler = (stateChange: ConditionStateChange): void => {
// As a performance optmization, if only Home Assistant state has changed
// (very frequent), and there aren't any related conditions, don't bother
// calling for the evealuation / listeners.
if (
Object.keys(stateChange.change).length === 1 &&
'state' in stateChange.change &&
!this._hasHAStateConditions
) {
return;
}
this._evaluate({ stateChange });
};
protected _evaluate(options?: {
stateChange?: ConditionStateChange;
callListeners?: boolean;
}): void {
const state = options?.stateChange?.new ?? this._stateManager?.getState();
let result = true;
let data: ConditionsEvaluationData = {};
for (const condition of this._conditions) {
const evaluation = this._evaluateCondition(
condition,
state,
options?.stateChange?.old,
);
if (!evaluation.result) {
result = false;
break;
}
data = {
...data,
...evaluation.data,
};
}
const evaluation: ConditionsEvaluationResult = result
? { result, data }
: { result };
if (!isEqual(evaluation, this._evaluation)) {
this._evaluation = evaluation;
if (options?.callListeners ?? true) {
this._listeners.forEach(
(listener) => this._evaluation && listener(this._evaluation),
);
}
}
}
protected _evaluateCondition(
condition: AdvancedCameraCardCondition,
newState?: ConditionState,
oldState?: ConditionState,
): ConditionsEvaluationResult {
switch (condition.condition) {
case undefined:
case 'state':
return {
result:
!!newState?.state &&
((!condition.state && !condition.state_not) ||
(condition.entity in newState.state &&
(!condition.state ||
(Array.isArray(condition.state)
? condition.state.includes(newState.state[condition.entity].state)
: condition.state === newState.state[condition.entity].state)) &&
(!condition.state_not ||
(Array.isArray(condition.state_not)
? !condition.state_not.includes(
newState.state[condition.entity].state,
)
: condition.state_not !== newState.state[condition.entity].state)))),
data: {
state: {
entity: condition.entity,
...(oldState?.state?.[condition.entity]?.state && {
from: oldState?.state?.[condition.entity]?.state,
}),
...(newState?.state?.[condition.entity]?.state && {
to: newState?.state?.[condition.entity]?.state,
}),
},
},
};
case 'view':
return {
result:
(!!newState?.view && condition.views?.includes(newState.view)) ||
(newState?.view !== oldState?.view && !condition.views?.length),
data: {
...((oldState?.view || newState?.view) && {
view: {
...(oldState?.view && { from: oldState.view }),
...(newState?.view && { to: newState.view }),
},
}),
},
};
case 'fullscreen':
return {
result:
newState?.fullscreen !== undefined &&
condition.fullscreen === newState.fullscreen,
};
case 'expand':
return {
result: newState?.expand !== undefined && condition.expand === newState.expand,
};
case 'camera':
return {
result:
(!!newState?.camera && !!condition.cameras?.includes(newState.camera)) ||
(newState?.camera !== oldState?.camera && !condition.cameras?.length),
data: {
...((oldState?.camera || newState?.camera) && {
camera: {
...(oldState?.camera && { from: oldState?.camera }),
...(newState?.camera && { to: newState?.camera }),
},
}),
},
};
case 'numeric_state':
return {
result:
!!newState?.state &&
condition.entity in newState.state &&
newState.state[condition.entity].state !== undefined &&
(condition.above === undefined ||
Number(newState.state[condition.entity].state) > condition.above) &&
(condition.below === undefined ||
Number(newState.state[condition.entity].state) < condition.below),
};
case 'user':
return {
result: !!newState?.user && condition.users.includes(newState.user.id),
};
case 'media_loaded':
return {
result:
newState?.mediaLoadedInfo !== undefined &&
condition.media_loaded === !!newState.mediaLoadedInfo,
};
case 'screen':
return { result: window.matchMedia(condition.media_query).matches };
case 'display_mode':
return {
result:
!!newState?.displayMode && condition.display_mode === newState.displayMode,
};
case 'triggered':
return {
result: condition.triggered.some((triggeredCameraID) =>
newState?.triggered?.has(triggeredCameraID),
),
};
case 'interaction':
return {
result:
newState?.interaction !== undefined &&
condition.interaction === newState.interaction,
};
case 'microphone':
return {
result:
(condition.connected === undefined ||
newState?.microphone?.connected === condition.connected) &&
(condition.muted === undefined ||
newState?.microphone?.muted === condition.muted),
};
case 'key':
return {
result:
!!newState?.keys &&
condition.key in newState.keys &&
(condition.state ?? 'down') === newState.keys[condition.key].state &&
(condition.ctrl === undefined ||
condition.ctrl === !!newState.keys[condition.key].ctrl) &&
(condition.alt === undefined ||
condition.alt === !!newState.keys[condition.key].alt) &&
(condition.meta === undefined ||
condition.meta === !!newState.keys[condition.key].meta) &&
(condition.shift === undefined ||
condition.shift === !!newState.keys[condition.key].shift),
};
case 'user_agent':
return {
result:
!!newState?.userAgent &&
(!condition.user_agent || condition.user_agent === newState.userAgent) &&
(condition.companion === undefined ||
condition.companion === isCompanionApp(newState.userAgent)) &&
(condition.user_agent_re === undefined ||
new RegExp(condition.user_agent_re).test(newState.userAgent)),
};
}
}
}
+38
View File
@@ -0,0 +1,38 @@
import { ConditionStateManager } from './state-manager';
export class ConditionStateManagerGetEvent extends Event {
public conditionStateManager?: ConditionStateManager;
constructor(eventInitDict?: EventInit) {
super('advanced-camera-card:condition-state-manager:get', eventInitDict);
}
}
/**
* Fetch the main ConditionStateManager via an event.
* @returns The ConditionStateManager or null if not found.
*/
export function getConditionStateManagerViaEvent(
element: HTMLElement,
): ConditionStateManager | null {
const getEvent = new ConditionStateManagerGetEvent({
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(getEvent);
return getEvent.conditionStateManager ?? null;
}
+60
View File
@@ -0,0 +1,60 @@
import { isEqual } from 'lodash-es';
import {
ConditionState,
ConditionStateChange,
ConditionStateListener,
ConditionStateManagerReadonlyInterface,
} from './types';
/**
* A class to manage state used in the evaluation of conditions.
*/
export class ConditionStateManager implements ConditionStateManagerReadonlyInterface {
protected _listeners: ConditionStateListener[] = [];
protected _state: ConditionState = {};
public addListener(listener: ConditionStateListener): void {
this._listeners.push(listener);
}
public removeListener(listener: ConditionStateListener): void {
this._listeners = this._listeners.filter((l) => l !== listener);
}
public getState(): ConditionState {
return this._state;
}
public setState(state: ConditionState): void {
this._processStateChange(this._calculateTrueChange(state));
}
protected _processStateChange(changeState: ConditionState): void {
if (!Object.keys(changeState).length) {
return;
}
const oldState = this._state;
this._state = {
...oldState,
...changeState,
};
this._callListeners({ old: oldState, change: changeState, new: this._state });
}
protected _calculateTrueChange(change: ConditionState): ConditionState {
const changeState: ConditionState = {};
for (const key of Object.keys(change)) {
if (!isEqual(change[key], this._state[key])) {
changeState[key] = change[key];
}
}
return changeState;
}
protected _callListeners = (stateChange: ConditionStateChange): void => {
this._listeners.forEach((listener) => listener(stateChange));
};
}
+69
View File
@@ -0,0 +1,69 @@
import { CurrentUser } from '@dermotduffy/custom-card-helpers';
import { HassEntities } from 'home-assistant-js-websocket';
import { ViewDisplayMode } from '../config/types';
import { MediaLoadedInfo } from '../types';
import { KeysState, MicrophoneState } from '../card-controller/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;
}
export interface ConditionStateChange {
old: ConditionState;
change: ConditionState;
new: ConditionState;
}
export type ConditionStateListener = (change: ConditionStateChange) => void;
export interface ConditionStateManagerReadonlyInterface {
addListener(listener: ConditionStateListener): void;
removeListener(listener: ConditionStateListener): void;
getState(): ConditionState;
}
interface ConditionsEvaluationDataFromTo {
from?: string;
to?: string;
}
interface ConditionsEvaluationDataState extends ConditionsEvaluationDataFromTo {
entity: string;
}
export interface ConditionsEvaluationData {
camera?: ConditionsEvaluationDataFromTo;
view?: ConditionsEvaluationDataFromTo;
state?: ConditionsEvaluationDataState;
}
interface ConditionsEvaluationResultTrue {
result: true;
data?: ConditionsEvaluationData;
}
interface ConditionsEvaluationResultFalse {
result: false;
}
export type ConditionsEvaluationResult =
| ConditionsEvaluationResultTrue
| ConditionsEvaluationResultFalse;
export type ConditionsListener = (result: ConditionsEvaluationResult) => void;
export interface ConditionsManagerReadonlyInterface {
addListener(listener: ConditionsListener): void;
removeListener(listener: ConditionsListener): void;
getEvaluation(): ConditionsEvaluationResult | null;
}
+3 -13
View File
@@ -685,7 +685,7 @@ export type StatusBarItem = z.infer<typeof statusBarItemSchema>;
const viewConditionSchema = z.object({
condition: z.literal('view'),
views: z.string().array(),
views: z.string().array().optional(),
});
const fullscreenConditionSchema = z.object({
condition: z.literal('fullscreen'),
@@ -697,7 +697,7 @@ const expandConditionSchema = z.object({
});
const cameraConditionSchema = z.object({
condition: z.literal('camera'),
cameras: z.string().array(),
cameras: z.string().array().optional(),
});
const mediaLoadedConditionSchema = z.object({
condition: z.literal('media_loaded'),
@@ -773,7 +773,7 @@ export type AdvancedCameraCardCondition = z.infer<
typeof advancedCameraCardConditionSchema
>;
export const advancedCameraCardConditionalSchema = z.object({
const advancedCameraCardConditionalSchema = z.object({
type: z.literal('custom:advanced-camera-card-conditional'),
conditions: advancedCameraCardConditionSchema.array(),
elements: z.lazy(() => pictureElementsSchema),
@@ -1328,16 +1328,6 @@ const liveConfigSchema = z
.default(liveConfigDefault);
export type LiveConfig = z.infer<typeof liveConfigSchema>;
// 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 advancedCameraCardConfigSchema 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
// *************************************************************************