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:
@@ -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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user