Convert conditions to HA style.

This commit is contained in:
Dermot Duffy
2024-03-10 18:09:17 -07:00
parent ff78cc51a1
commit 829f411acc
11 changed files with 1334 additions and 476 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ export class AutomationsManager {
for (const automation of this._automations ?? []) {
const shouldExecute = this._api
.getConditionsManager()
.evaluateCondition(automation.conditions);
.evaluateConditions(automation.conditions);
const actions = shouldExecute ? automation.actions : automation.actions_not;
const priorEvaluation = this._priorEvaluations.get(automation);
this._priorEvaluations.set(automation, shouldExecute);
+95 -93
View File
@@ -5,13 +5,17 @@ import { copyConfig } from '../config-mgmt';
import {
FrigateCardCondition,
frigateConditionalSchema,
MicrophoneConditionState,
OverrideConfigurationKey,
RawFrigateCardConfig,
ViewDisplayMode,
} from '../config/types';
import { CardConditionAPI } from './types';
interface MicrophoneConditionState {
connected?: boolean;
muted?: boolean;
}
interface ConditionState {
view?: string;
fullscreen?: boolean;
@@ -26,13 +30,13 @@ interface ConditionState {
user?: CurrentUser;
}
export class ConditionEvaluateRequestEvent extends Event {
public condition: FrigateCardCondition;
export class ConditionsEvaluateRequestEvent extends Event {
public conditions: FrigateCardCondition[];
public evaluation?: boolean;
constructor(condition: FrigateCardCondition, eventInitDict?: EventInit) {
super('frigate-card:condition:evaluate', eventInitDict);
this.condition = condition;
constructor(conditions: FrigateCardCondition[], eventInitDict?: EventInit) {
super('frigate-card:conditions:evaluate', eventInitDict);
this.conditions = conditions;
}
}
@@ -42,13 +46,13 @@ export class ConditionEvaluateRequestEvent extends Event {
*/
export function evaluateConditionViaEvent(
element: HTMLElement,
condition?: FrigateCardCondition,
conditions?: FrigateCardCondition[],
): boolean {
if (!condition) {
if (!conditions) {
return true;
}
const evaluateEvent = new ConditionEvaluateRequestEvent(condition, {
const evaluateEvent = new ConditionsEvaluateRequestEvent(conditions, {
bubbles: true,
composed: true,
});
@@ -70,7 +74,7 @@ export function evaluateConditionViaEvent(
}
type RawOverrides = {
conditions: FrigateCardCondition;
conditions: FrigateCardCondition[];
overrides: RawFrigateCardConfig;
}[];
@@ -84,7 +88,7 @@ export function getOverriddenConfig(
let overridden = false;
if (configOverrides) {
for (const override of configOverrides) {
if (manager.evaluateCondition(override.conditions, stateOverrides)) {
if (manager.evaluateConditions(override.conditions, stateOverrides)) {
merge(output, override.overrides);
overridden = true;
}
@@ -156,7 +160,7 @@ export class ConditionsManager {
const getAllConditions = (): FrigateCardCondition[] => {
const config = this._api.getConfigManager().getConfig();
const conditions: FrigateCardCondition[] = [];
config?.overrides?.forEach((override) => conditions.push(override.conditions));
config?.overrides?.forEach((override) => conditions.push(...override.conditions));
// Element conditions can be arbitrarily nested underneath conditionals and
// custom elements that this card may not known. Here we recursively parse
@@ -164,7 +168,7 @@ export class ConditionsManager {
const getElementsConditions = (data: unknown): void => {
const parseResult = frigateConditionalSchema.safeParse(data);
if (parseResult.success) {
conditions.push(parseResult.data.conditions);
conditions.push(...parseResult.data.conditions);
parseResult.data.elements?.forEach(getElementsConditions);
} else if (data && typeof data === 'object') {
Object.keys(data).forEach((key) => getElementsConditions(data[key]));
@@ -175,15 +179,12 @@ export class ConditionsManager {
};
const conditions = getAllConditions();
this._hasHAStateConditions = conditions.some(
(condition) =>
!!condition.state?.length ||
!!condition.numeric_state?.length ||
!!condition.users?.length,
this._hasHAStateConditions = conditions.some((conditionObj) =>
['state', 'numeric_state', 'user'].includes(conditionObj.condition),
);
conditions.forEach((condition) => {
if (condition.media_query) {
const mql = window.matchMedia(condition.media_query);
conditions.forEach((conditionObj) => {
if (conditionObj.condition === 'screen') {
const mql = window.matchMedia(conditionObj.media_query);
mql.addEventListener('change', this._mediaQueryTrigger);
this._mediaQueries.push(mql);
}
@@ -210,8 +211,17 @@ export class ConditionsManager {
return this._epoch;
}
public evaluateCondition(
condition: Readonly<FrigateCardCondition>,
public evaluateConditions(
conditions: Readonly<FrigateCardCondition>[],
stateOverrides?: Partial<ConditionState>,
): boolean {
return conditions.every((conditionObj) =>
this._evaluateCondition(conditionObj, stateOverrides),
);
}
protected _evaluateCondition(
conditionObj: Readonly<FrigateCardCondition>,
stateOverrides?: Partial<ConditionState>,
): boolean {
const state = {
@@ -219,79 +229,71 @@ export class ConditionsManager {
...stateOverrides,
};
let result = true;
if (condition.view?.length) {
result &&= !!state?.view && condition.view.includes(state.view);
}
if (condition.fullscreen !== undefined) {
result &&=
state.fullscreen !== undefined && condition.fullscreen === state.fullscreen;
}
if (condition.expand !== undefined) {
result &&= state.expand !== undefined && condition.expand === state.expand;
}
if (condition.camera?.length) {
result &&= !!state.camera && condition.camera.includes(state.camera);
}
if (condition.state?.length) {
for (const stateTest of condition.state) {
result &&=
switch (conditionObj.condition) {
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 'state':
return (
!!state.state &&
((!stateTest.state && !stateTest.state_not) ||
(stateTest.entity in state.state &&
(!stateTest.state ||
(Array.isArray(stateTest.state)
? stateTest.state.includes(state.state[stateTest.entity].state)
: stateTest.state === state.state[stateTest.entity].state)) &&
(!stateTest.state_not ||
(Array.isArray(stateTest.state_not)
? !stateTest.state_not.includes(state.state[stateTest.entity].state)
: stateTest.state_not !== state.state[stateTest.entity].state))));
}
}
if (condition.numeric_state?.length) {
for (const stateTest of condition.numeric_state) {
result &&=
((!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 'numeric_state':
return (
!!state.state &&
stateTest.entity in state.state &&
state.state[stateTest.entity].state !== undefined &&
(stateTest.above === undefined ||
Number(state.state[stateTest.entity].state) > stateTest.above) &&
(stateTest.below === undefined ||
Number(state.state[stateTest.entity].state) < stateTest.below);
}
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.media_loaded !== undefined &&
conditionObj.media_loaded === state.media_loaded
);
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)
);
}
if (condition.users?.length) {
result &&= !!state.user && condition.users.includes(state.user.id);
}
if (condition.media_loaded !== undefined) {
result &&=
state.media_loaded !== undefined &&
condition.media_loaded === state.media_loaded;
}
if (condition.media_query) {
result &&= window.matchMedia(condition.media_query).matches;
}
if (condition.display_mode) {
result &&= !!state.displayMode && condition.display_mode === state.displayMode;
}
if (condition.triggered?.length) {
result &&= condition.triggered.some((triggeredCameraID) =>
state.triggered?.has(triggeredCameraID),
);
}
if (condition.interaction !== undefined) {
result &&=
state.interaction !== undefined && condition.interaction === state.interaction;
}
if (condition.microphone) {
result &&=
(condition.microphone.connected === undefined ||
state.microphone?.connected === condition.microphone.connected) &&
(condition.microphone.muted === undefined ||
state.microphone?.muted === condition.microphone.muted);
}
return result;
}
protected _createEpoch(): ConditionsManagerEpoch {
+3 -3
View File
@@ -8,7 +8,7 @@ import { ViewContext } from 'view';
import 'web-dialog';
import pkg from '../package.json';
import { actionHandler } from './action-handler-directive.js';
import { ConditionEvaluateRequestEvent } from './card-controller/conditions-manager.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';
@@ -341,10 +341,10 @@ class FrigateCard extends LitElement {
this._menuButtonController.removeDynamicMenuButton(ev.detail);
this.requestUpdate();
}}
@frigate-card:condition:evaluate=${(ev: ConditionEvaluateRequestEvent) => {
@frigate-card:conditions:evaluate=${(ev: ConditionsEvaluateRequestEvent) => {
ev.evaluation = this._controller
.getConditionsManager()
?.evaluateCondition(ev.condition);
?.evaluateConditions(ev.conditions);
}}
>
</frigate-card-elements>`
+110 -1
View File
@@ -3,8 +3,13 @@ import get from 'lodash-es/get';
import isEqual from 'lodash-es/isEqual';
import set from 'lodash-es/set';
import unset from 'lodash-es/unset';
import { RawFrigateCardConfig, RawFrigateCardConfigArray } from './config/types';
import {
FrigateCardCondition,
RawFrigateCardConfig,
RawFrigateCardConfigArray,
} from './config/types';
import {
CONF_AUTOMATIONS,
CONF_CAMERAS,
CONF_CAMERAS_GLOBAL_DIMENSIONS_LAYOUT,
CONF_CAMERAS_GLOBAL_IMAGE,
@@ -342,6 +347,99 @@ const conditionMediaLoadedTransform = (data: unknown): boolean => {
return false;
};
/**
* Transform a single object with multiple conditions to multiple objects with
* single conditions
* @param data Input data.
* @returns `true` if the configuration was modified.
*/
const conditionToConditionsTransform = (data: unknown): boolean => {
if (
typeof data !== 'object' ||
!data ||
typeof data['conditions'] !== 'object' ||
!data['conditions']
) {
return false;
}
const oldConditions = data['conditions'];
const newConditions: FrigateCardCondition[] = [];
if (oldConditions['view'] !== undefined) {
newConditions.push({
condition: 'view' as const,
views: oldConditions['view'],
});
}
if (oldConditions['fullscreen'] !== undefined) {
newConditions.push({
condition: 'fullscreen' as const,
fullscreen: oldConditions['fullscreen'],
});
}
if (oldConditions['expand'] !== undefined) {
newConditions.push({
condition: 'expand' as const,
expand: oldConditions['expand'],
});
}
if (oldConditions['camera'] !== undefined) {
newConditions.push({
condition: 'camera' as const,
cameras: oldConditions['camera'],
});
}
if (oldConditions['media_loaded'] !== undefined) {
newConditions.push({
condition: 'media_loaded' as const,
media_loaded: oldConditions['media_loaded'],
});
}
if (oldConditions['state'] !== undefined && Array.isArray(oldConditions['state'])) {
for (const stateCondition of oldConditions['state']) {
if (
typeof stateCondition === 'object' &&
stateCondition &&
(stateCondition['state'] !== undefined ||
stateCondition['state_not'] !== undefined ||
stateCondition['entity'] !== undefined)
) {
newConditions.push({
condition: 'state' as const,
...(stateCondition['state'] && {
state: stateCondition['state'],
}),
...(stateCondition['state_not'] && {
state_not: stateCondition['state_not'],
}),
...(stateCondition['entity'] && {
entity: stateCondition['entity'],
}),
});
}
}
}
if (oldConditions['media_query'] !== undefined) {
newConditions.push({
condition: 'screen' as const,
media_query: oldConditions['media_query'],
});
}
// These conditions did not exist prior to v6.0.0 and so are not converted:
// - display_mode
// - triggered
// - interaction
// - microphone
if (newConditions.length) {
data['conditions'] = newConditions;
return true;
}
return false;
};
/**
* Transform service_data -> data
* See: https://github.com/dermotduffy/frigate-hass-card/issues/1103
@@ -569,4 +667,15 @@ const UPGRADES = [
upgradeMoveToWithOverrides('live.layout', CONF_CAMERAS_GLOBAL_DIMENSIONS_LAYOUT),
deleteWithOverrides('media_viewer.layout'),
deleteWithOverrides('image.layout'),
upgradeArrayOfObjects(CONF_OVERRIDES, conditionToConditionsTransform),
(data: unknown): boolean => {
return upgradeObjectRecursively(conditionToConditionsTransform)(
typeof data === 'object' && data ? data[CONF_ELEMENTS] : {},
);
},
(data: unknown): boolean => {
return upgradeObjectRecursively(conditionToConditionsTransform)(
typeof data === 'object' && data ? data[CONF_AUTOMATIONS] : {},
);
},
];
+66 -33
View File
@@ -401,7 +401,7 @@ const imageSchema = elementsBaseSchema.extend({
});
// https://www.home-assistant.io/dashboards/conditional/#state
const stateCondition = z.object({
const stateConditionSchema = z.object({
condition: z.literal('state'),
entity: z.string(),
state: z.string().or(z.string().array()).optional(),
@@ -409,38 +409,36 @@ const stateCondition = z.object({
});
// https://www.home-assistant.io/dashboards/conditional/#numeric-state
const numericCondition = z.object({
const numericStateConditionSchema = z.object({
condition: z.literal('numeric_state'),
entity: z.string(),
above: z.number().optional(),
below: z.number().optional(),
});
const mediaQuerySchema = z.string();
// https://www.home-assistant.io/dashboards/conditional/#screen
const screenCondition = z.object({
const screenConditionSchema = z.object({
condition: z.literal('screen'),
media_query: mediaQuerySchema,
media_query: z.string(),
});
// https://www.home-assistant.io/dashboards/conditional/#user
const usersCondition = z.object({
const usersConditionSchema = z.object({
condition: z.literal('user'),
users: z.string().array().min(1),
});
const stockCondition = z.union([
stateCondition,
numericCondition,
screenCondition,
usersCondition,
const stockConditionSchema = z.discriminatedUnion('condition', [
stateConditionSchema,
numericStateConditionSchema,
screenConditionSchema,
usersConditionSchema,
]);
// https://www.home-assistant.io/lovelace/picture-elements/#image-element
export const conditionalSchema = z.object({
type: z.literal('conditional'),
conditions: stockCondition.array(),
conditions: stockConditionSchema.array(),
elements: z.lazy(() => pictureElementsSchema),
});
@@ -516,32 +514,67 @@ export type MenuItem = MenuIcon | MenuStateIcon | MenuSubmenu | MenuSubmenuSelec
// Custom Element Configuration: Conditions
// *************************************************************************
const viewConditionSchema = z.object({
condition: z.literal('view'),
views: z.string().array(),
});
const fullscreenConditionSchema = z.object({
condition: z.literal('fullscreen'),
fullscreen: z.boolean(),
});
const expandConditionSchema = z.object({
condition: z.literal('expand'),
expand: z.boolean(),
});
const cameraConditionSchema = z.object({
condition: z.literal('camera'),
cameras: z.string().array(),
});
const mediaLoadedConditionSchema = z.object({
condition: z.literal('media_loaded'),
media_loaded: z.boolean(),
});
const displayModeConditionSchema = z.object({
condition: z.literal('display_mode'),
display_mode: viewDisplayModeSchema,
});
const triggeredConditionSchema = z.object({
condition: z.literal('triggered'),
triggered: z.string().array(),
});
const interactionConditionSchema = z.object({
condition: z.literal('interaction'),
interaction: z.boolean(),
});
const microphoneConditionSchema = z.object({
condition: z.literal('microphone'),
connected: z.boolean().optional(),
muted: z.boolean().optional(),
});
export type MicrophoneConditionState = z.infer<typeof microphoneConditionSchema>;
export const frigateCardConditionSchema = z.object({
view: z.string().array().optional(),
fullscreen: z.boolean().optional(),
expand: z.boolean().optional(),
camera: z.string().array().optional(),
media_loaded: z.boolean().optional(),
state: stateCondition.array().optional(),
numeric_state: numericCondition.array().optional(),
media_query: mediaQuerySchema.optional(),
display_mode: viewDisplayModeSchema.optional(),
triggered: z.string().array().optional(),
interaction: z.boolean().optional(),
microphone: microphoneConditionSchema.optional(),
users: z.string().array().optional()
});
export const frigateCardConditionSchema = z.discriminatedUnion('condition', [
// Stock conditions:
stateConditionSchema,
numericStateConditionSchema,
screenConditionSchema,
usersConditionSchema,
// Custom conditions:
viewConditionSchema,
fullscreenConditionSchema,
expandConditionSchema,
cameraConditionSchema,
mediaLoadedConditionSchema,
displayModeConditionSchema,
triggeredConditionSchema,
interactionConditionSchema,
microphoneConditionSchema,
]);
export type FrigateCardCondition = z.infer<typeof frigateCardConditionSchema>;
export const frigateConditionalSchema = z.object({
type: z.literal('custom:frigate-card-conditional'),
conditions: frigateCardConditionSchema,
conditions: frigateCardConditionSchema.array(),
elements: z.lazy(() => pictureElementsSchema),
});
export type FrigateConditional = z.infer<typeof frigateConditionalSchema>;
@@ -1034,7 +1067,7 @@ export type LiveConfig = z.infer<typeof liveConfigSchema>;
const liveOverridesSchema = z
.object({
conditions: frigateCardConditionSchema,
conditions: frigateCardConditionSchema.array(),
overrides: liveOverridableConfigSchema,
})
.array()
@@ -1551,7 +1584,7 @@ export type OverrideConfigurationKey = keyof z.infer<typeof overrideConfiguratio
const overridesSchema = z
.object({
conditions: frigateCardConditionSchema,
conditions: frigateCardConditionSchema.array(),
overrides: overrideConfigurationSchema,
})
.array()
@@ -1565,7 +1598,7 @@ const automationActionSchema = actionSchema.array();
export type AutomationActions = z.infer<typeof automationActionSchema>;
const automationSchema = z.object({
conditions: frigateCardConditionSchema,
conditions: frigateCardConditionSchema.array(),
actions: automationActionSchema.optional(),
actions_not: automationActionSchema.optional(),
});
+2
View File
@@ -1,6 +1,8 @@
export const REPO_URL = 'https://github.com/dermotduffy/frigate-hass-card' as const;
export const TROUBLESHOOTING_URL = `${REPO_URL}#troubleshooting` as const;
export const CONF_AUTOMATIONS = 'automations' as const;
export const CONF_CAMERAS = 'cameras' as const;
export const CONF_CAMERAS_ARRAY_CAMERA_ENTITY =
`${CONF_CAMERAS}.#.camera_entity` as const;