mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-01 12:41:43 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19231b9e78 | |||
| e7daf09a1a | |||
| fff1568898 | |||
| db8bd28b07 | |||
| e773ba4ded | |||
| a9a2d17741 | |||
| 49a7814115 | |||
| 002bf491bf | |||
| 43fcd1b0a4 | |||
| ab031ab139 | |||
| 07030e6575 | |||
| c32ae22f63 | |||
| 585db17e86 | |||
| 28739f7fd3 | |||
| 8db3f168a5 | |||
| aa2c8564ed | |||
| aaf5986fd7 | |||
| 8c20a1041f |
@@ -1,17 +1,24 @@
|
||||
import type {
|
||||
Condition,
|
||||
TimeCondition,
|
||||
VisibilityCondition,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
|
||||
/**
|
||||
* Extract media queries from conditions recursively
|
||||
*/
|
||||
export function extractMediaQueries(conditions: Condition[]): string[] {
|
||||
export function extractMediaQueries(
|
||||
conditions: VisibilityCondition[]
|
||||
): string[] {
|
||||
return conditions.reduce<string[]>((array, c) => {
|
||||
if ("conditions" in c && c.conditions) {
|
||||
array.push(...extractMediaQueries(c.conditions));
|
||||
}
|
||||
if (c.condition === "screen" && c.media_query) {
|
||||
if (
|
||||
"condition" in c &&
|
||||
c.condition === "screen" &&
|
||||
"media_query" in c &&
|
||||
c.media_query
|
||||
) {
|
||||
array.push(c.media_query);
|
||||
}
|
||||
return array;
|
||||
@@ -22,14 +29,16 @@ export function extractMediaQueries(conditions: Condition[]): string[] {
|
||||
* Extract time conditions from conditions recursively
|
||||
*/
|
||||
export function extractTimeConditions(
|
||||
conditions: Condition[]
|
||||
conditions: VisibilityCondition[]
|
||||
): TimeCondition[] {
|
||||
return conditions.reduce<TimeCondition[]>((array, c) => {
|
||||
if ("conditions" in c && c.conditions) {
|
||||
array.push(...extractTimeConditions(c.conditions));
|
||||
}
|
||||
if (c.condition === "time") {
|
||||
array.push(c);
|
||||
if ("condition" in c && c.condition === "time") {
|
||||
// Dashboard `time` is always the client-side lovelace shape; core `time`
|
||||
// is intentionally excluded from VisibilityCondition.
|
||||
array.push(c as TimeCondition);
|
||||
}
|
||||
return array;
|
||||
}, []);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { listenMediaQuery } from "../dom/media_query";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type {
|
||||
Condition,
|
||||
ConditionContext,
|
||||
TimeCondition,
|
||||
VisibilityCondition,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
|
||||
import { extractMediaQueries, extractTimeConditions } from "./extract";
|
||||
import { calculateNextTimeUpdate } from "./time-calculator";
|
||||
|
||||
@@ -16,95 +15,68 @@ import { calculateNextTimeUpdate } from "./time-calculator";
|
||||
const MAX_TIMEOUT_DELAY = 2147483647;
|
||||
|
||||
/**
|
||||
* Helper to setup media query listeners for conditional visibility
|
||||
* Schedule a callback to fire at the next boundary of a time condition,
|
||||
* rescheduling itself afterwards. Delays beyond the setTimeout maximum are
|
||||
* capped and re-scheduled without firing (so the boundary is only reported
|
||||
* once it is actually reached). Registers a single cleanup function that
|
||||
* clears the pending timeout.
|
||||
*/
|
||||
export function setupMediaQueryListeners(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
function scheduleTimeBoundaryListener(
|
||||
getHass: () => HomeAssistant,
|
||||
timeCondition: Omit<TimeCondition, "condition">,
|
||||
addListener: (unsub: () => void) => void,
|
||||
onUpdate: (conditionsMet: boolean) => void,
|
||||
getContext?: () => ConditionContext
|
||||
onBoundary: () => void
|
||||
): void {
|
||||
const mediaQueries = extractMediaQueries(conditions);
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
if (mediaQueries.length === 0) return;
|
||||
const scheduleUpdate = () => {
|
||||
// Read hass lazily so timezone changes are picked up on the next boundary.
|
||||
const delay = calculateNextTimeUpdate(getHass(), timeCondition);
|
||||
|
||||
// Optimization for single media query
|
||||
const hasOnlyMediaQuery =
|
||||
conditions.length === 1 &&
|
||||
conditions[0].condition === "screen" &&
|
||||
!!conditions[0].media_query;
|
||||
if (delay === undefined) return;
|
||||
|
||||
mediaQueries.forEach((mediaQuery) => {
|
||||
const unsub = listenMediaQuery(mediaQuery, (matches) => {
|
||||
if (hasOnlyMediaQuery) {
|
||||
onUpdate(matches);
|
||||
} else {
|
||||
const context = getContext?.() ?? {};
|
||||
const conditionsMet = checkConditionsMet(conditions, hass, context);
|
||||
onUpdate(conditionsMet);
|
||||
// Cap delay to prevent setTimeout overflow
|
||||
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (delay <= MAX_TIMEOUT_DELAY) {
|
||||
onBoundary();
|
||||
}
|
||||
});
|
||||
addListener(unsub);
|
||||
scheduleUpdate();
|
||||
}, cappedDelay);
|
||||
};
|
||||
|
||||
// Register cleanup function once, outside of scheduleUpdate
|
||||
addListener(() => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
scheduleUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to setup time-based listeners for conditional visibility
|
||||
* Observe the client-evaluated parts of a condition tree — `screen` media
|
||||
* queries and `time` boundaries — and invoke `onChange` whenever one of them
|
||||
* could have flipped.
|
||||
*
|
||||
* This does not evaluate the conditions itself: the caller recombines client
|
||||
* and server results on notification. Used by `ConditionEvaluatorController`,
|
||||
* which merges these client signals with the results of `subscribe_condition`
|
||||
* subscriptions.
|
||||
*/
|
||||
export function setupTimeListeners(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
export function observeConditionChanges(
|
||||
conditions: VisibilityCondition[],
|
||||
getHass: () => HomeAssistant,
|
||||
addListener: (unsub: () => void) => void,
|
||||
onUpdate: (conditionsMet: boolean) => void,
|
||||
getContext?: () => ConditionContext
|
||||
onChange: () => void
|
||||
): void {
|
||||
const timeConditions = extractTimeConditions(conditions);
|
||||
extractMediaQueries(conditions).forEach((mediaQuery) => {
|
||||
addListener(listenMediaQuery(mediaQuery, () => onChange()));
|
||||
});
|
||||
|
||||
if (timeConditions.length === 0) return;
|
||||
|
||||
timeConditions.forEach((timeCondition) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const scheduleUpdate = () => {
|
||||
const delay = calculateNextTimeUpdate(hass, timeCondition);
|
||||
|
||||
if (delay === undefined) return;
|
||||
|
||||
// Cap delay to prevent setTimeout overflow
|
||||
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (delay <= MAX_TIMEOUT_DELAY) {
|
||||
const context = getContext?.() ?? {};
|
||||
const conditionsMet = checkConditionsMet(conditions, hass, context);
|
||||
onUpdate(conditionsMet);
|
||||
}
|
||||
scheduleUpdate();
|
||||
}, cappedDelay);
|
||||
};
|
||||
|
||||
// Register cleanup function once, outside of scheduleUpdate
|
||||
addListener(() => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
scheduleUpdate();
|
||||
extractTimeConditions(conditions).forEach((timeCondition) => {
|
||||
scheduleTimeBoundaryListener(getHass, timeCondition, addListener, onChange);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up all condition listeners (media query, time) for conditional visibility.
|
||||
*/
|
||||
export function setupConditionListeners(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
addListener: (unsub: () => void) => void,
|
||||
onUpdate: (conditionsMet: boolean) => void,
|
||||
getContext?: () => ConditionContext
|
||||
): void {
|
||||
setupMediaQueryListeners(conditions, hass, addListener, onUpdate, getContext);
|
||||
setupTimeListeners(conditions, hass, addListener, onUpdate, getContext);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { Condition as CoreCondition } from "../../data/automation";
|
||||
import type { VisibilityCondition } from "../../panels/lovelace/common/validate-condition";
|
||||
import {
|
||||
isLogicalCondition,
|
||||
isServerCondition,
|
||||
translateToCoreCondition,
|
||||
} from "./translate";
|
||||
|
||||
/** A maximal server subtree, to be opened as one `subscribe_condition`. */
|
||||
export interface ServerSubtree {
|
||||
id: string;
|
||||
coreCondition: CoreCondition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single client-only condition leaf (`screen`, `user`,
|
||||
* `view_columns`, `location`, `time`). Returns `undefined` when the outcome is
|
||||
* not yet determinable (e.g. context not available).
|
||||
*/
|
||||
export type ClientConditionEvaluator = (
|
||||
condition: VisibilityCondition
|
||||
) => boolean | undefined;
|
||||
|
||||
/** Server subtree results keyed by {@link ServerSubtree.id}; `undefined` = not yet reported. */
|
||||
export type ServerConditionResults = Record<string, boolean | undefined>;
|
||||
|
||||
export interface SplitConditionTree {
|
||||
/** Maximal server subtrees, each to be opened as one `subscribe_condition`. */
|
||||
serverSubtrees: ServerSubtree[];
|
||||
/**
|
||||
* Combine client + server results into the overall visibility using
|
||||
* three-valued (Kleene) logic. Returns `undefined` while the outcome still
|
||||
* depends on a server subtree that has not reported yet.
|
||||
*/
|
||||
evaluate: (
|
||||
clientEvaluator: ClientConditionEvaluator,
|
||||
serverResults: ServerConditionResults
|
||||
) => boolean | undefined;
|
||||
}
|
||||
|
||||
type EvalNode = (
|
||||
clientEvaluator: ClientConditionEvaluator,
|
||||
serverResults: ServerConditionResults
|
||||
) => boolean | undefined;
|
||||
|
||||
// Three-valued logic combinators (true / false / undefined = unknown). `false`
|
||||
// dominates AND and `true` dominates OR regardless of any unknown sibling.
|
||||
const andNode =
|
||||
(children: EvalNode[]): EvalNode =>
|
||||
(clientEvaluator, serverResults) => {
|
||||
let unknown = false;
|
||||
for (const child of children) {
|
||||
const value = child(clientEvaluator, serverResults);
|
||||
if (value === false) return false;
|
||||
if (value === undefined) unknown = true;
|
||||
}
|
||||
return unknown ? undefined : true;
|
||||
};
|
||||
|
||||
const orNode =
|
||||
(children: EvalNode[]): EvalNode =>
|
||||
(clientEvaluator, serverResults) => {
|
||||
let unknown = false;
|
||||
for (const child of children) {
|
||||
const value = child(clientEvaluator, serverResults);
|
||||
if (value === true) return true;
|
||||
if (value === undefined) unknown = true;
|
||||
}
|
||||
return unknown ? undefined : false;
|
||||
};
|
||||
|
||||
const notNode =
|
||||
(child: EvalNode): EvalNode =>
|
||||
(clientEvaluator, serverResults) => {
|
||||
const value = child(clientEvaluator, serverResults);
|
||||
return value === undefined ? undefined : !value;
|
||||
};
|
||||
|
||||
const serverLeaf =
|
||||
(id: string): EvalNode =>
|
||||
(_clientEvaluator, serverResults) =>
|
||||
serverResults[id];
|
||||
|
||||
const clientLeaf =
|
||||
(condition: VisibilityCondition): EvalNode =>
|
||||
(clientEvaluator) =>
|
||||
clientEvaluator(condition);
|
||||
|
||||
/**
|
||||
* Split a dashboard visibility condition tree into:
|
||||
*
|
||||
* - a flat list of **maximal server subtrees** (`serverSubtrees`), each
|
||||
* translated to core format and meant to back one `subscribe_condition`; and
|
||||
* - an **`evaluate`** function that recombines those subtree results with
|
||||
* locally-evaluated client leaves into the overall visibility.
|
||||
*
|
||||
* The top-level array is treated as an implicit `AND`. Sibling server
|
||||
* conditions sharing a logical parent (including that implicit top-level AND)
|
||||
* are grouped into a *single* subscription using the parent's operator, to
|
||||
* avoid subscription fan-out. A `not` combines its children with `AND` before
|
||||
* negating, matching lovelace `not` semantics (¬(AND of children)).
|
||||
*/
|
||||
export const splitConditionTree = (
|
||||
conditions: VisibilityCondition[]
|
||||
): SplitConditionTree => {
|
||||
const serverSubtrees: ServerSubtree[] = [];
|
||||
let nextId = 0;
|
||||
|
||||
const addSubtree = (coreCondition: CoreCondition): EvalNode => {
|
||||
const id = String(nextId);
|
||||
nextId += 1;
|
||||
serverSubtrees.push({ id, coreCondition });
|
||||
return serverLeaf(id);
|
||||
};
|
||||
|
||||
// Partition children into client/server, group the server siblings into one
|
||||
// subscription, and recurse into the client ones. `groupOperator` is the
|
||||
// operator used to combine the grouped server siblings.
|
||||
const buildSiblings = (
|
||||
children: VisibilityCondition[],
|
||||
groupOperator: "and" | "or"
|
||||
): EvalNode[] => {
|
||||
const serverChildren: VisibilityCondition[] = [];
|
||||
const clientChildren: VisibilityCondition[] = [];
|
||||
for (const child of children) {
|
||||
(isServerCondition(child) ? serverChildren : clientChildren).push(child);
|
||||
}
|
||||
|
||||
const nodes: EvalNode[] = [];
|
||||
|
||||
if (serverChildren.length === 1) {
|
||||
nodes.push(addSubtree(translateToCoreCondition(serverChildren[0])));
|
||||
} else if (serverChildren.length > 1) {
|
||||
nodes.push(
|
||||
addSubtree({
|
||||
condition: groupOperator,
|
||||
conditions: serverChildren.map(translateToCoreCondition),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
for (const child of clientChildren) {
|
||||
nodes.push(build(child));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
};
|
||||
|
||||
// Only ever reached for client-class nodes (server subtrees are grouped and
|
||||
// translated whole by `buildSiblings`).
|
||||
const build = (condition: VisibilityCondition): EvalNode => {
|
||||
if (isLogicalCondition(condition)) {
|
||||
const children = condition.conditions ?? [];
|
||||
if (condition.condition === "or") {
|
||||
return orNode(buildSiblings(children, "or"));
|
||||
}
|
||||
if (condition.condition === "not") {
|
||||
return notNode(andNode(buildSiblings(children, "and")));
|
||||
}
|
||||
return andNode(buildSiblings(children, "and"));
|
||||
}
|
||||
// Defensive: a server leaf reaching here still becomes a subscription.
|
||||
if (isServerCondition(condition)) {
|
||||
return addSubtree(translateToCoreCondition(condition));
|
||||
}
|
||||
return clientLeaf(condition);
|
||||
};
|
||||
|
||||
const root = andNode(buildSiblings(conditions, "and"));
|
||||
|
||||
return {
|
||||
serverSubtrees,
|
||||
evaluate: (clientEvaluator, serverResults) =>
|
||||
root(clientEvaluator, serverResults),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,251 @@
|
||||
import type {
|
||||
Condition as CoreCondition,
|
||||
NumericStateCondition as CoreNumericStateCondition,
|
||||
StateCondition as CoreStateCondition,
|
||||
} from "../../data/automation";
|
||||
import type {
|
||||
LegacyCondition,
|
||||
NumericStateCondition as LovelaceNumericStateCondition,
|
||||
StateCondition as LovelaceStateCondition,
|
||||
VisibilityCondition,
|
||||
VisibilityLogicalCondition,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import { isValidEntityId } from "../entity/valid_entity_id";
|
||||
|
||||
/**
|
||||
* Lovelace condition types evaluated on the client; these have no usable core
|
||||
* equivalent for dashboards and are never sent to `subscribe_condition`.
|
||||
*/
|
||||
const CLIENT_CONDITION_TYPES = new Set([
|
||||
"screen",
|
||||
"user",
|
||||
"view_columns",
|
||||
"location",
|
||||
"time",
|
||||
]);
|
||||
|
||||
const LOGICAL_CONDITION_TYPES = new Set(["and", "or", "not"]);
|
||||
|
||||
/** Type guard for the `and` / `or` / `not` combinators. */
|
||||
export const isLogicalCondition = (
|
||||
condition: VisibilityCondition
|
||||
): condition is VisibilityLogicalCondition =>
|
||||
"condition" in condition && LOGICAL_CONDITION_TYPES.has(condition.condition);
|
||||
|
||||
/**
|
||||
* Whether a condition must be evaluated server-side (via `subscribe_condition`).
|
||||
*
|
||||
* Leaves: everything except the client-only lovelace types is server-class,
|
||||
* including legacy `{ entity, state }` conditions (treated as `state`) and any
|
||||
* integration-provided condition.
|
||||
*
|
||||
* Compounds (`and` / `or` / `not`) are server-class only when *every*
|
||||
* descendant is, so a single client leaf anywhere forces the whole compound
|
||||
* client-side, where it becomes a combinator wrapping server subtrees (see
|
||||
* `splitConditionTree`). An empty compound is vacuously server-class.
|
||||
*/
|
||||
export const isServerCondition = (condition: VisibilityCondition): boolean => {
|
||||
if (isLogicalCondition(condition)) {
|
||||
return (condition.conditions ?? []).every(isServerCondition);
|
||||
}
|
||||
// Legacy lovelace condition without a `condition` key → treated as `state`.
|
||||
if (!("condition" in condition)) {
|
||||
return true;
|
||||
}
|
||||
return !CLIENT_CONDITION_TYPES.has(condition.condition);
|
||||
};
|
||||
|
||||
/** Inverse of {@link isServerCondition}. */
|
||||
export const isClientCondition = (condition: VisibilityCondition): boolean =>
|
||||
!isServerCondition(condition);
|
||||
|
||||
/**
|
||||
* Whether *every* leaf in the tree is a client-only condition, so the whole
|
||||
* tree can be evaluated and validated client-side without any
|
||||
* `subscribe_condition` round-trip. Distinct from {@link isClientCondition},
|
||||
* which is true when *any* leaf is client-side.
|
||||
*/
|
||||
export const isPureClientCondition = (
|
||||
condition: VisibilityCondition
|
||||
): boolean =>
|
||||
isLogicalCondition(condition)
|
||||
? (condition.conditions ?? []).every(isPureClientCondition)
|
||||
: isClientCondition(condition);
|
||||
|
||||
/**
|
||||
* Translate a server-class lovelace condition into its core automation
|
||||
* equivalent. Core-format conditions (and condition types with no lovelace
|
||||
* counterpart, like `template` / `sun` / `zone` / `device` / integration
|
||||
* conditions) are passed through untouched.
|
||||
*
|
||||
* The caller is responsible for only translating server-class conditions
|
||||
* ({@link isServerCondition}); passing a client-only condition just returns it
|
||||
* unchanged.
|
||||
*/
|
||||
export const translateToCoreCondition = (
|
||||
condition: VisibilityCondition
|
||||
): CoreCondition => {
|
||||
// Legacy lovelace condition: { entity, state, state_not } with no `condition`.
|
||||
if (!("condition" in condition)) {
|
||||
return translateStateCondition({ condition: "state", ...condition });
|
||||
}
|
||||
|
||||
if (isLogicalCondition(condition)) {
|
||||
return translateLogicalCondition(condition);
|
||||
}
|
||||
|
||||
switch (condition.condition) {
|
||||
case "state":
|
||||
return translateStateCondition(condition as LovelaceStateCondition);
|
||||
case "numeric_state":
|
||||
return translateNumericStateCondition(
|
||||
condition as LovelaceNumericStateCondition
|
||||
);
|
||||
default:
|
||||
// Already core format (sun, zone, template, device, integration, or a
|
||||
// core `state` / `numeric_state` carrying `entity_id`) → pass through.
|
||||
return condition as CoreCondition;
|
||||
}
|
||||
};
|
||||
|
||||
// A core condition that always evaluates to false — ¬(AND of nothing) = ¬true.
|
||||
// Used where checkConditionsMet short-circuits to false (an incomplete config),
|
||||
// so we never emit a schema-invalid condition that would break a grouped
|
||||
// subscription.
|
||||
const alwaysFalseCondition = (): CoreCondition => ({
|
||||
condition: "not",
|
||||
conditions: [{ condition: "and", conditions: [] }],
|
||||
});
|
||||
|
||||
const translateStateCondition = (
|
||||
condition: LovelaceStateCondition | CoreStateCondition | LegacyCondition
|
||||
): CoreCondition => {
|
||||
// Already core format — distinguished from lovelace by `entity_id`.
|
||||
if ("entity_id" in condition) {
|
||||
return condition as CoreStateCondition;
|
||||
}
|
||||
|
||||
const lovelace = condition as LovelaceStateCondition;
|
||||
|
||||
// Incomplete config: no entity, or no comparison value. checkConditionsMet
|
||||
// returns false for these (and a `state` condition with no `entity_id` /
|
||||
// `state` is invalid for core), so resolve to a clean always-false.
|
||||
if (
|
||||
lovelace.entity === undefined ||
|
||||
(lovelace.state === undefined && lovelace.state_not === undefined)
|
||||
) {
|
||||
return alwaysFalseCondition();
|
||||
}
|
||||
|
||||
const base = {
|
||||
condition: "state" as const,
|
||||
entity_id: lovelace.entity,
|
||||
...(lovelace.attribute !== undefined
|
||||
? { attribute: lovelace.attribute }
|
||||
: {}),
|
||||
};
|
||||
|
||||
// KNOWN LIMITATION: when the compared value is itself an entity id, lovelace
|
||||
// (checkStateCondition -> getValueFromEntityId) resolves *any* entity to its
|
||||
// live state, but core's `state` condition only dereferences `input_*`
|
||||
// entities and compares everything else literally. A value referencing a
|
||||
// non-`input_*` entity therefore changes meaning after delegation. This is
|
||||
// niche (the visibility editor does not offer entity-as-value) and left as a
|
||||
// future enhancement — a faithful, reactive fix would emit a `template`
|
||||
// condition. See https://github.com/home-assistant/frontend/issues/52836.
|
||||
|
||||
// `state` wins over `state_not` when both are present, mirroring
|
||||
// checkConditionsMet (`state ?? state_not`, positive branch when `state`).
|
||||
if (lovelace.state !== undefined) {
|
||||
return { ...base, state: lovelace.state } as CoreStateCondition;
|
||||
}
|
||||
|
||||
// Core has no `state_not`; wrap a positive `state` in `not`.
|
||||
return {
|
||||
condition: "not",
|
||||
conditions: [{ ...base, state: lovelace.state_not } as CoreStateCondition],
|
||||
};
|
||||
};
|
||||
|
||||
const translateNumericStateCondition = (
|
||||
condition: LovelaceNumericStateCondition | CoreNumericStateCondition
|
||||
): CoreCondition => {
|
||||
if ("entity_id" in condition) {
|
||||
return condition as CoreNumericStateCondition;
|
||||
}
|
||||
const lovelace = condition as LovelaceNumericStateCondition;
|
||||
const core: CoreNumericStateCondition = {
|
||||
condition: "numeric_state",
|
||||
entity_id: lovelace.entity as string,
|
||||
};
|
||||
if (lovelace.attribute !== undefined) {
|
||||
core.attribute = lovelace.attribute;
|
||||
}
|
||||
const above = translateNumericBound(lovelace.above);
|
||||
if (above !== undefined) {
|
||||
core.above = above;
|
||||
}
|
||||
const below = translateNumericBound(lovelace.below);
|
||||
if (below !== undefined) {
|
||||
core.below = below;
|
||||
}
|
||||
return core;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reconcile a lovelace numeric bound with core's interpretation. Lovelace
|
||||
* resolves a string bound to an entity's state only when that entity exists,
|
||||
* otherwise falling back to `Number(...)` (which yields `NaN` for junk, leaving
|
||||
* the bound effectively ignored). Core instead treats *every* string bound as
|
||||
* an entity id and errors when it is not one. To preserve lovelace behavior:
|
||||
*
|
||||
* - a finite numeric string (`"5"`, `"10.5"`, even `""` → 0) coerces to a
|
||||
* number (the entity-id regex matches `"10.5"`, so test `Number()` first);
|
||||
* - a genuine entity-id reference passes through for core to resolve;
|
||||
* - anything else (junk like `"foo"`, or non-finite like `"1e400"`) is dropped,
|
||||
* matching lovelace's "NaN ⇒ ignored" and never emitting a non-finite number
|
||||
* (which is not JSON-serializable).
|
||||
*/
|
||||
const translateNumericBound = (
|
||||
bound: string | number | undefined
|
||||
): string | number | undefined => {
|
||||
if (typeof bound !== "string") {
|
||||
return bound;
|
||||
}
|
||||
const numeric = Number(bound);
|
||||
if (!isNaN(numeric) && isFinite(numeric)) {
|
||||
return numeric;
|
||||
}
|
||||
if (isValidEntityId(bound)) {
|
||||
return bound;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const translateLogicalCondition = (
|
||||
condition: VisibilityLogicalCondition
|
||||
): CoreCondition => {
|
||||
// Lovelace treats a logical condition with no `conditions` key as vacuously
|
||||
// true (checkAnd/Or/NotCondition all early-return on a missing list).
|
||||
if (condition.conditions === undefined) {
|
||||
return { condition: "and", conditions: [] };
|
||||
}
|
||||
|
||||
const conditions = condition.conditions.map(translateToCoreCondition);
|
||||
|
||||
if (condition.condition === "not") {
|
||||
// Lovelace `not` means ¬(AND of children); core `not` means ¬(OR of
|
||||
// children). Wrapping the children in an `and` preserves the lovelace
|
||||
// meaning for any arity — including an empty `not`, which becomes ¬(AND of
|
||||
// nothing) = ¬true = false, matching checkConditionsMet. A single child is
|
||||
// unambiguous (¬(OR of one) = ¬(AND of one)) and left unwrapped for a
|
||||
// tidier persisted form.
|
||||
if (conditions.length === 1) {
|
||||
return { condition: "not", conditions };
|
||||
}
|
||||
return { condition: "not", conditions: [{ condition: "and", conditions }] };
|
||||
}
|
||||
|
||||
// Empty `and` (true) / `or` (false) already agree between lovelace and core.
|
||||
return { condition: condition.condition, conditions };
|
||||
};
|
||||
@@ -0,0 +1,329 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { subscribeCondition } from "../../data/automation";
|
||||
import type {
|
||||
Condition,
|
||||
ConditionContext,
|
||||
VisibilityCondition,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { observeConditionChanges } from "../condition/listeners";
|
||||
import type {
|
||||
ClientConditionEvaluator,
|
||||
ServerConditionResults,
|
||||
SplitConditionTree,
|
||||
} from "../condition/split";
|
||||
import { splitConditionTree } from "../condition/split";
|
||||
|
||||
/** Tri-state visibility outcome. `unknown` = a server subtree has not reported yet. */
|
||||
export type ConditionEvaluation = "visible" | "hidden" | "unknown";
|
||||
|
||||
export interface ConditionEvaluatorOptions {
|
||||
/** Called whenever the combined result or error changes. */
|
||||
onResult: (result: ConditionEvaluation, error?: string) => void;
|
||||
/** Debounce (ms) before (re)opening subscriptions when the tree changes. */
|
||||
resubscribeDelay?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_RESUBSCRIBE_DELAY = 50;
|
||||
|
||||
/**
|
||||
* Reactive controller that keeps a dashboard visibility condition tree
|
||||
* evaluated live by combining:
|
||||
*
|
||||
* - `subscribe_condition` subscriptions, one per maximal server subtree
|
||||
* (`state`, `numeric_state`, `template`, `sun`, `zone`, `device`,
|
||||
* integration conditions), and
|
||||
* - locally-evaluated client leaves (`screen`, `user`, `view_columns`,
|
||||
* `location`, `time`), reacting to media-query / time-boundary / hass /
|
||||
* context changes.
|
||||
*
|
||||
* The host calls {@link observe} whenever its inputs change; the controller
|
||||
* only (re)subscribes when the *condition tree* changes (debounced) and merely
|
||||
* recomputes for hass/context changes. Subscriptions are torn down on host
|
||||
* disconnect and re-opened on reconnect. The combined result uses three-valued
|
||||
* logic so the host can render an explicit `unknown` state without flashing
|
||||
* while server results are still pending.
|
||||
*/
|
||||
export class ConditionEvaluatorController implements ReactiveController {
|
||||
private _host: ReactiveControllerHost;
|
||||
|
||||
private readonly _onResult: ConditionEvaluatorOptions["onResult"];
|
||||
|
||||
private readonly _resubscribeDelay: number;
|
||||
|
||||
private _conditions?: VisibilityCondition[];
|
||||
|
||||
private _hass?: HomeAssistant;
|
||||
|
||||
private _getContext?: () => ConditionContext;
|
||||
|
||||
private _connected = false;
|
||||
|
||||
// Structural signature of the tree the live subscriptions/listeners are for,
|
||||
// and of the tree a pending (debounced) re-subscribe will switch to. Compared
|
||||
// by value (not array reference) so a host re-deriving the array each render
|
||||
// does not starve the debounce or needlessly drop subscriptions.
|
||||
private _subscribedSignature?: string;
|
||||
|
||||
private _pendingSignature?: string;
|
||||
|
||||
// Memoize the signature for a stable array reference to avoid re-stringifying
|
||||
// on every host update.
|
||||
private _lastConditionsRef?: VisibilityCondition[];
|
||||
|
||||
private _lastSignature?: string;
|
||||
|
||||
private _split?: SplitConditionTree;
|
||||
|
||||
private _serverResults: ServerConditionResults = {};
|
||||
|
||||
private _subtreeErrors: Record<string, string | undefined> = {};
|
||||
|
||||
private _subscriptions: Promise<UnsubscribeFunc>[] = [];
|
||||
|
||||
private _listeners: (() => void)[] = [];
|
||||
|
||||
// Bumped on every teardown so late-arriving async results are ignored.
|
||||
private _generation = 0;
|
||||
|
||||
private _resubscribeTimeout?: ReturnType<typeof setTimeout>;
|
||||
|
||||
private _result: ConditionEvaluation = "unknown";
|
||||
|
||||
private _error?: string;
|
||||
|
||||
private _notifiedResult?: ConditionEvaluation;
|
||||
|
||||
private _notifiedError?: string;
|
||||
|
||||
constructor(
|
||||
host: ReactiveControllerHost,
|
||||
options: ConditionEvaluatorOptions
|
||||
) {
|
||||
this._host = host;
|
||||
this._onResult = options.onResult;
|
||||
this._resubscribeDelay =
|
||||
options.resubscribeDelay ?? DEFAULT_RESUBSCRIBE_DELAY;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public get result(): ConditionEvaluation {
|
||||
return this._result;
|
||||
}
|
||||
|
||||
public get error(): string | undefined {
|
||||
return this._error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide the latest inputs. Cheap to call on every host update: it only
|
||||
* (re)subscribes when the condition tree reference changes, otherwise it just
|
||||
* recomputes the client-dependent parts.
|
||||
*/
|
||||
public observe(
|
||||
conditions: VisibilityCondition[] | undefined,
|
||||
hass: HomeAssistant | undefined,
|
||||
getContext?: () => ConditionContext
|
||||
): void {
|
||||
this._conditions = conditions;
|
||||
this._hass = hass;
|
||||
this._getContext = getContext;
|
||||
this._sync();
|
||||
}
|
||||
|
||||
public hostConnected(): void {
|
||||
this._connected = true;
|
||||
this._sync();
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._connected = false;
|
||||
this._teardown();
|
||||
// Nothing backs the last result once subscriptions are closed; report
|
||||
// `unknown` (and force the notification through) so a detached/reconnecting
|
||||
// host never renders a stale, no-longer-live visibility.
|
||||
this._notifiedResult = undefined;
|
||||
this._notifiedError = undefined;
|
||||
this._setResult("unknown", undefined);
|
||||
}
|
||||
|
||||
private _signatureOf(
|
||||
conditions: VisibilityCondition[] | undefined
|
||||
): string | undefined {
|
||||
if (conditions === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (conditions === this._lastConditionsRef) {
|
||||
return this._lastSignature;
|
||||
}
|
||||
this._lastConditionsRef = conditions;
|
||||
this._lastSignature = JSON.stringify(conditions);
|
||||
return this._lastSignature;
|
||||
}
|
||||
|
||||
private _sync(): void {
|
||||
if (!this._connected) {
|
||||
return;
|
||||
}
|
||||
const signature = this._signatureOf(this._conditions);
|
||||
// Re-subscribe only when the tree we are (or are about to be) subscribed to
|
||||
// actually differs by value — not merely by array reference.
|
||||
const targetSignature = this._pendingSignature ?? this._subscribedSignature;
|
||||
if (signature !== targetSignature) {
|
||||
this._pendingSignature = signature;
|
||||
this._scheduleResubscribe();
|
||||
}
|
||||
// Always recompute so client leaves (and the current split) stay live, even
|
||||
// while a re-subscribe is pending.
|
||||
this._recompute();
|
||||
}
|
||||
|
||||
private _scheduleResubscribe(): void {
|
||||
if (this._resubscribeTimeout !== undefined) {
|
||||
clearTimeout(this._resubscribeTimeout);
|
||||
}
|
||||
this._resubscribeTimeout = setTimeout(() => {
|
||||
this._resubscribeTimeout = undefined;
|
||||
this._subscribe();
|
||||
}, this._resubscribeDelay);
|
||||
}
|
||||
|
||||
private _subscribe(): void {
|
||||
this._teardown();
|
||||
|
||||
const conditions = this._conditions;
|
||||
const hass = this._hass;
|
||||
this._subscribedSignature = this._signatureOf(conditions);
|
||||
this._pendingSignature = undefined;
|
||||
|
||||
if (!conditions || !hass) {
|
||||
this._setResult("unknown", undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const split = splitConditionTree(conditions);
|
||||
this._split = split;
|
||||
|
||||
const generation = this._generation;
|
||||
const connection: Connection = hass.connection;
|
||||
|
||||
for (const subtree of split.serverSubtrees) {
|
||||
this._serverResults[subtree.id] = undefined;
|
||||
const subscription = subscribeCondition(
|
||||
connection,
|
||||
(message) => {
|
||||
if (generation !== this._generation) {
|
||||
return;
|
||||
}
|
||||
if (message.error !== undefined) {
|
||||
this._serverResults[subtree.id] = false;
|
||||
this._subtreeErrors[subtree.id] =
|
||||
typeof message.error === "string"
|
||||
? message.error
|
||||
: message.error.message;
|
||||
} else {
|
||||
this._serverResults[subtree.id] = message.result;
|
||||
this._subtreeErrors[subtree.id] = undefined;
|
||||
}
|
||||
this._recompute();
|
||||
},
|
||||
subtree.coreCondition
|
||||
);
|
||||
subscription.catch((err: unknown) => {
|
||||
if (generation !== this._generation) {
|
||||
return;
|
||||
}
|
||||
this._serverResults[subtree.id] = false;
|
||||
this._subtreeErrors[subtree.id] =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
this._recompute();
|
||||
});
|
||||
this._subscriptions.push(subscription);
|
||||
}
|
||||
|
||||
observeConditionChanges(
|
||||
conditions,
|
||||
() => this._hass ?? hass,
|
||||
(unsub) => this._listeners.push(unsub),
|
||||
() => this._recompute()
|
||||
);
|
||||
|
||||
this._recompute();
|
||||
}
|
||||
|
||||
private _recompute(): void {
|
||||
if (!this._split || !this._hass) {
|
||||
this._setResult("unknown", undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const hass = this._hass;
|
||||
const context = this._getContext?.() ?? {};
|
||||
const clientEvaluator: ClientConditionEvaluator = (condition) => {
|
||||
try {
|
||||
// Only client-class leaves reach here, and those are all lovelace
|
||||
// Condition members.
|
||||
return checkConditionsMet([condition as Condition], hass, context);
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const value = this._split.evaluate(clientEvaluator, this._serverResults);
|
||||
const result: ConditionEvaluation =
|
||||
value === undefined ? "unknown" : value ? "visible" : "hidden";
|
||||
|
||||
this._setResult(result, this._combinedError());
|
||||
}
|
||||
|
||||
private _combinedError(): string | undefined {
|
||||
for (const error of Object.values(this._subtreeErrors)) {
|
||||
if (error) {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _setResult(
|
||||
result: ConditionEvaluation,
|
||||
error: string | undefined
|
||||
): void {
|
||||
this._result = result;
|
||||
this._error = error;
|
||||
if (result === this._notifiedResult && error === this._notifiedError) {
|
||||
return;
|
||||
}
|
||||
this._notifiedResult = result;
|
||||
this._notifiedError = error;
|
||||
this._onResult(result, error);
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
|
||||
private _teardown(): void {
|
||||
// Invalidate any in-flight subscription callbacks.
|
||||
this._generation += 1;
|
||||
if (this._resubscribeTimeout !== undefined) {
|
||||
clearTimeout(this._resubscribeTimeout);
|
||||
this._resubscribeTimeout = undefined;
|
||||
}
|
||||
for (const subscription of this._subscriptions) {
|
||||
subscription.then((unsub) => unsub()).catch(() => undefined);
|
||||
}
|
||||
this._subscriptions = [];
|
||||
for (const unsub of this._listeners) {
|
||||
unsub();
|
||||
}
|
||||
this._listeners = [];
|
||||
this._split = undefined;
|
||||
this._serverResults = {};
|
||||
this._subtreeErrors = {};
|
||||
this._subscribedSignature = undefined;
|
||||
this._pendingSignature = undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import type {
|
||||
Condition,
|
||||
ConditionContext,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { setupConditionListeners } from "../condition/listeners";
|
||||
|
||||
/**
|
||||
* Reactive controller that manages the media-query and time-based listeners
|
||||
* needed to keep a set of lovelace visibility conditions evaluated live.
|
||||
*
|
||||
* The host is responsible for the actual evaluation (e.g. computing visible /
|
||||
* hidden / invalid state); the controller only triggers it via the supplied
|
||||
* `onUpdate` callback when something the conditions depend on changes. Call
|
||||
* `setup()` whenever the conditions change; the controller clears previous
|
||||
* listeners and re-subscribes. Listeners are automatically released when the
|
||||
* host disconnects.
|
||||
*/
|
||||
export class ConditionListenersController implements ReactiveController {
|
||||
private _unsubs: (() => void)[] = [];
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this.clear();
|
||||
}
|
||||
|
||||
public setup(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
onUpdate: () => void,
|
||||
getContext?: () => ConditionContext
|
||||
): void {
|
||||
this.clear();
|
||||
if (!conditions.length) {
|
||||
return;
|
||||
}
|
||||
setupConditionListeners(
|
||||
conditions,
|
||||
hass,
|
||||
(unsub) => this._unsubs.push(unsub),
|
||||
() => onUpdate(),
|
||||
getContext
|
||||
);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
for (const unsub of this._unsubs) {
|
||||
unsub();
|
||||
}
|
||||
this._unsubs = [];
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { PropertyValues, ReactiveElement } from "lit";
|
||||
import { state } from "lit/decorators";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { setupConditionListeners } from "../common/condition/listeners";
|
||||
import type { ConditionEvaluation } from "../common/controllers/condition-evaluator-controller";
|
||||
import { ConditionEvaluatorController } from "../common/controllers/condition-evaluator-controller";
|
||||
import { maxColumnsContext } from "../panels/lovelace/common/context";
|
||||
import type {
|
||||
Condition,
|
||||
ConditionContext,
|
||||
VisibilityCondition,
|
||||
} from "../panels/lovelace/common/validate-condition";
|
||||
import {
|
||||
addEntityToCondition,
|
||||
checkConditionsMet,
|
||||
} from "../panels/lovelace/common/validate-condition";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
type Constructor<T> = abstract new (...args: any[]) => T;
|
||||
|
||||
@@ -20,22 +26,30 @@ export interface ConditionalConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mixin to handle conditional listeners for visibility control
|
||||
* Mixin to handle conditional visibility control.
|
||||
*
|
||||
* Provides lifecycle management for listeners that control conditional
|
||||
* visibility of components.
|
||||
* Visibility conditions are evaluated by a {@link ConditionEvaluatorController}:
|
||||
* stateful conditions (`state`, `numeric_state`, `template`, `sun`, `zone`,
|
||||
* `device`, integration conditions) are delegated to core via
|
||||
* `subscribe_condition`, while client-only conditions (`screen`, `user`,
|
||||
* `view_columns`, `location`, `time`) are evaluated locally. The host stays
|
||||
* declarative — it never evaluates conditions itself.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Extend your component with ConditionalListenerMixin<YourConfigType>(ReactiveElement)
|
||||
* 2. Ensure component has config.visibility or _config.visibility property with conditions
|
||||
* 3. Ensure component has _updateVisibility() or _updateElement() method
|
||||
* 4. Override setupConditionalListeners() if custom behavior needed (e.g., filter conditions)
|
||||
* 1. Extend with `ConditionalListenerMixin<YourConfigType>(ReactiveElement)`.
|
||||
* 2. Provide conditions via `config.visibility` / `_config.visibility`, or by
|
||||
* overriding `setupConditionalListeners()` and calling
|
||||
* `super.setupConditionalListeners(customConditions)`.
|
||||
* 3. Implement `_updateVisibility()` (or `_updateElement()`) and have it derive
|
||||
* visibility from {@link _conditionsVisible} rather than evaluating
|
||||
* conditions directly.
|
||||
*
|
||||
* The mixin automatically:
|
||||
* - Sets up listeners when component connects to DOM
|
||||
* - Cleans up listeners when component disconnects from DOM
|
||||
* - Handles conditional visibility based on defined conditions
|
||||
* - Consumes column count from the view via Lit Context
|
||||
* - feeds the evaluator on connect and whenever `hass`, the config, or the
|
||||
* column count change;
|
||||
* - notifies the host (`_updateVisibility` / `_updateElement`) when the verdict
|
||||
* changes; and
|
||||
* - tears down subscriptions on disconnect (handled by the controller).
|
||||
*/
|
||||
export const ConditionalListenerMixin = <
|
||||
TConfig extends ConditionalConfig = ConditionalConfig,
|
||||
@@ -43,8 +57,6 @@ export const ConditionalListenerMixin = <
|
||||
superClass: Constructor<ReactiveElement>
|
||||
) => {
|
||||
abstract class ConditionalListenerClass extends superClass {
|
||||
private __listeners: (() => void)[] = [];
|
||||
|
||||
protected _config?: TConfig;
|
||||
|
||||
public config?: TConfig;
|
||||
@@ -57,6 +69,51 @@ export const ConditionalListenerMixin = <
|
||||
|
||||
protected _conditionContext: ConditionContext = {};
|
||||
|
||||
// The conditions currently being evaluated (a card/badge/section/view
|
||||
// `visibility`, or the conditional card/row `conditions`). Retained so the
|
||||
// optimistic synchronous seed evaluates exactly what the evaluator
|
||||
// subscribed to.
|
||||
private __conditions?: VisibilityCondition[];
|
||||
|
||||
// Latest server-aware verdict from the evaluator. `unknown` until a server
|
||||
// subtree first reports (or immediately for an all-client tree).
|
||||
private __conditionResult: ConditionEvaluation = "unknown";
|
||||
|
||||
// Cache for the entity-folded array fed to the evaluator. Rebuilt only when
|
||||
// the source tree reference or the entity context changes, so the
|
||||
// evaluator's reference-based signature memo keeps hitting on hass-only
|
||||
// updates instead of re-stringifying every tick.
|
||||
private __observedSource?: VisibilityCondition[];
|
||||
|
||||
private __observedEntityId?: string;
|
||||
|
||||
private __observed?: VisibilityCondition[];
|
||||
|
||||
// Value signature of the source tree, used to drop the cached verdict when
|
||||
// the tree changes by value so `_conditionsVisible` re-seeds for it.
|
||||
private __conditionsSignature?: string;
|
||||
|
||||
private __conditionEvaluator = new ConditionEvaluatorController(this, {
|
||||
// The synchronous seed in `_conditionsVisible` covers the initial frame,
|
||||
// so there is no need to delay (re)subscribing.
|
||||
resubscribeDelay: 0,
|
||||
onResult: (result) => {
|
||||
this.__conditionResult = result;
|
||||
// The forced `unknown` on disconnect only matters to hosts that render
|
||||
// the evaluator's result; we drive visibility imperatively, so ignore
|
||||
// notifications once detached.
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
const config = this._config || this.config;
|
||||
if (this._updateVisibility) {
|
||||
this._updateVisibility();
|
||||
} else if (this._updateElement && config) {
|
||||
this._updateElement(config);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
protected _updateElement?(config: TConfig): void;
|
||||
|
||||
protected _updateVisibility?(conditionsMet?: boolean): void;
|
||||
@@ -66,11 +123,6 @@ export const ConditionalListenerMixin = <
|
||||
this.setupConditionalListeners();
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.clearConditionalListeners();
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues) {
|
||||
super.willUpdate(changedProperties);
|
||||
if (changedProperties.has("_maxColumns")) {
|
||||
@@ -83,67 +135,105 @@ export const ConditionalListenerMixin = <
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has("_maxColumns")) {
|
||||
this._updateVisibility?.();
|
||||
// Re-feed the evaluator after the host has settled its inputs (e.g.
|
||||
// `_conditionContext.entity_id`, which consumers set in `willUpdate`).
|
||||
// The evaluator only re-subscribes when the *tree* changes; a
|
||||
// hass/context change merely recomputes.
|
||||
if (
|
||||
changedProperties.has("hass") ||
|
||||
changedProperties.has("config") ||
|
||||
changedProperties.has("_config") ||
|
||||
changedProperties.has("_maxColumns")
|
||||
) {
|
||||
this.setupConditionalListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear conditional listeners
|
||||
* Resolve the observed conditions to a visibility boolean.
|
||||
*
|
||||
* This method is called when the component is disconnected from the DOM.
|
||||
* It clears all the listeners that were set up by the setupConditionalListeners() method.
|
||||
* Prefers the evaluator's server-aware verdict; while a server subtree is
|
||||
* still pending (`unknown`) it falls back to an optimistic synchronous
|
||||
* client evaluation. That fallback is exact for the legacy lovelace
|
||||
* condition types (so existing dashboards never flash) and resolves to
|
||||
* hidden for core-only conditions (`template` / `sun` / …) until the server
|
||||
* reports — erring toward hiding rather than leaking content.
|
||||
*
|
||||
* Consumers call this from `_updateVisibility` instead of evaluating
|
||||
* `checkConditionsMet` themselves.
|
||||
*/
|
||||
protected clearConditionalListeners(): void {
|
||||
this.__listeners.forEach((unsub) => unsub());
|
||||
this.__listeners = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a conditional listener to the list of listeners
|
||||
*
|
||||
* This method is called when a new listener is added.
|
||||
* It adds the listener to the list of listeners.
|
||||
*
|
||||
* @param unsubscribe - The unsubscribe function to call when the listener is no longer needed
|
||||
* @returns void
|
||||
*/
|
||||
protected addConditionalListener(unsubscribe: () => void): void {
|
||||
this.__listeners.push(unsubscribe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup conditional listeners for visibility control
|
||||
*
|
||||
* Default implementation:
|
||||
* - Checks config.visibility or _config.visibility for conditions (if not provided)
|
||||
* - Sets up appropriate listeners based on condition types
|
||||
* - Calls _updateVisibility() or _updateElement() when conditions change
|
||||
*
|
||||
* Override this method to customize behavior (e.g., filter conditions first)
|
||||
* and call super.setupConditionalListeners(customConditions) to reuse the base implementation
|
||||
*
|
||||
* @param conditions - Optional conditions array. If not provided, will check config.visibility or _config.visibility
|
||||
*/
|
||||
protected setupConditionalListeners(conditions?: Condition[]): void {
|
||||
const config = this.config || this._config;
|
||||
const finalConditions = conditions || config?.visibility;
|
||||
|
||||
if (!finalConditions || !this.hass) {
|
||||
return;
|
||||
protected _conditionsVisible(): boolean {
|
||||
const conditions = this.__conditions;
|
||||
if (!conditions || conditions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
setupConditionListeners(
|
||||
finalConditions,
|
||||
if (this.__conditionResult !== "unknown") {
|
||||
return this.__conditionResult === "visible";
|
||||
}
|
||||
if (!this.hass) {
|
||||
return true;
|
||||
}
|
||||
return checkConditionsMet(
|
||||
conditions as Condition[],
|
||||
this.hass,
|
||||
this._conditionContext
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed the current conditions to the evaluator.
|
||||
*
|
||||
* Override to supply a custom condition set (e.g. the conditional card's
|
||||
* `conditions`) and call `super.setupConditionalListeners(customConditions)`.
|
||||
*
|
||||
* @param conditions - Optional conditions. Defaults to
|
||||
* `config.visibility` / `_config.visibility`.
|
||||
*/
|
||||
protected setupConditionalListeners(
|
||||
conditions?: VisibilityCondition[]
|
||||
): void {
|
||||
// Prefer the resolved `_config` (e.g. a strategy-generated section config)
|
||||
// over the raw `config`, matching the pre-refactor evaluation source.
|
||||
const config = this._config || this.config;
|
||||
const finalConditions =
|
||||
conditions ?? (config?.visibility as VisibilityCondition[] | undefined);
|
||||
const entityId = this._conditionContext.entity_id;
|
||||
|
||||
this.__conditions = finalConditions;
|
||||
|
||||
// Re-derive the entity-folded array only when the source tree reference or
|
||||
// the entity context actually changes — not on every hass tick — so the
|
||||
// evaluator keeps seeing a stable array reference and its signature memo
|
||||
// keeps hitting. The evaluator translates to core format with no notion of
|
||||
// the host's `entity_id` context, so fold it in here (mirroring
|
||||
// `checkConditionsMet`, which reads `entity_id || entity || context`).
|
||||
if (
|
||||
finalConditions !== this.__observedSource ||
|
||||
entityId !== this.__observedEntityId
|
||||
) {
|
||||
// When the tree changes by *value*, drop the cached verdict so
|
||||
// `_conditionsVisible` re-seeds for the new tree instead of reusing the
|
||||
// previous tree's result for a frame.
|
||||
const signature = finalConditions
|
||||
? JSON.stringify(finalConditions)
|
||||
: undefined;
|
||||
if (signature !== this.__conditionsSignature) {
|
||||
this.__conditionsSignature = signature;
|
||||
this.__conditionResult = "unknown";
|
||||
}
|
||||
this.__observedSource = finalConditions;
|
||||
this.__observedEntityId = entityId;
|
||||
this.__observed =
|
||||
finalConditions && entityId
|
||||
? ((finalConditions as Condition[]).map((c) =>
|
||||
addEntityToCondition(c, entityId)
|
||||
) as VisibilityCondition[])
|
||||
: finalConditions;
|
||||
}
|
||||
|
||||
this.__conditionEvaluator.observe(
|
||||
this.__observed,
|
||||
this.hass,
|
||||
(unsub) => this.addConditionalListener(unsub),
|
||||
(conditionsMet) => {
|
||||
if (this._updateVisibility) {
|
||||
this._updateVisibility(conditionsMet);
|
||||
} else if (this._updateElement && config) {
|
||||
this._updateElement(config);
|
||||
}
|
||||
},
|
||||
() => this._conditionContext
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||
import { getConfigEntityId } from "../common/get-config-entity-id";
|
||||
import { checkConditionsMet } from "../common/validate-condition";
|
||||
import { createBadgeElement } from "../create-element/create-badge-element";
|
||||
import { createErrorBadgeConfig } from "../create-element/create-element-base";
|
||||
import type { LovelaceBadge } from "../types";
|
||||
@@ -165,14 +164,7 @@ export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
|
||||
return;
|
||||
}
|
||||
|
||||
const visible =
|
||||
conditionsMet ??
|
||||
(!this.config?.visibility ||
|
||||
checkConditionsMet(
|
||||
this.config.visibility,
|
||||
this.hass,
|
||||
this._conditionContext
|
||||
));
|
||||
const visible = conditionsMet ?? this._conditionsVisible();
|
||||
this._setElementVisibility(visible);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-m
|
||||
import { migrateLayoutToGridOptions } from "../common/compute-card-grid-size";
|
||||
import { computeCardSize } from "../common/compute-card-size";
|
||||
import { getConfigEntityId } from "../common/get-config-entity-id";
|
||||
import { checkConditionsMet } from "../common/validate-condition";
|
||||
import { tryCreateCardElement } from "../create-element/create-card-element";
|
||||
import { createErrorCardElement } from "../create-element/create-element-base";
|
||||
import type { LovelaceCard, LovelaceGridOptions } from "../types";
|
||||
@@ -262,14 +261,7 @@ export class HuiCard extends ConditionalListenerMixin<LovelaceCardConfig>(
|
||||
return;
|
||||
}
|
||||
|
||||
const visible =
|
||||
conditionsMet ??
|
||||
(!this.config?.visibility ||
|
||||
checkConditionsMet(
|
||||
this.config.visibility,
|
||||
this.hass,
|
||||
this._conditionContext
|
||||
));
|
||||
const visible = conditionsMet ?? this._conditionsVisible();
|
||||
this._setElementVisibility(visible);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,17 +2,23 @@ import {
|
||||
mdiAccount,
|
||||
mdiAmpersand,
|
||||
mdiCalendarClock,
|
||||
mdiCodeBraces,
|
||||
mdiDevices,
|
||||
mdiGateOr,
|
||||
mdiMapMarker,
|
||||
mdiMapMarkerRadius,
|
||||
mdiNotEqualVariant,
|
||||
mdiNumeric,
|
||||
mdiResponsive,
|
||||
mdiStateMachine,
|
||||
mdiViewColumnOutline,
|
||||
mdiWeatherSunny,
|
||||
} from "@mdi/js";
|
||||
import type { Condition } from "./validate-condition";
|
||||
|
||||
export const ICON_CONDITION: Record<Condition["condition"], string> = {
|
||||
// Keyed by the condition `condition` string. Covers the client-only lovelace
|
||||
// types, the logical combinators, and the core-format server types edited via
|
||||
// the automation condition editors (template/sun/zone/device).
|
||||
export const ICON_CONDITION: Record<string, string> = {
|
||||
view_columns: mdiViewColumnOutline,
|
||||
location: mdiMapMarker,
|
||||
numeric_state: mdiNumeric,
|
||||
@@ -23,4 +29,8 @@ export const ICON_CONDITION: Record<Condition["condition"], string> = {
|
||||
and: mdiAmpersand,
|
||||
not: mdiNotEqualVariant,
|
||||
or: mdiGateOr,
|
||||
template: mdiCodeBraces,
|
||||
sun: mdiWeatherSunny,
|
||||
zone: mdiMapMarkerRadius,
|
||||
device: mdiDevices,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,15 @@ import {
|
||||
type WeekdayShort,
|
||||
} from "../../../common/datetime/weekday";
|
||||
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
|
||||
import type {
|
||||
NumericStateCondition as CoreNumericStateCondition,
|
||||
PlatformCondition as CorePlatformCondition,
|
||||
StateCondition as CoreStateCondition,
|
||||
SunCondition,
|
||||
TemplateCondition,
|
||||
ZoneCondition,
|
||||
} from "../../../data/automation";
|
||||
import type { DeviceCondition } from "../../../data/device/device_automation";
|
||||
import { UNKNOWN } from "../../../data/entity/entity";
|
||||
import { getUserPerson } from "../../../data/person";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
@@ -99,6 +108,77 @@ export interface NotCondition extends BaseCondition {
|
||||
conditions?: Condition[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard visibility conditions
|
||||
* ===============================
|
||||
*
|
||||
* Historically, dashboard visibility (`visibility` on cards/badges/sections/
|
||||
* views and `conditions` on the conditional card/row/element) used the
|
||||
* lovelace-only {@link Condition} format above, evaluated synchronously on the
|
||||
* client by {@link checkConditionsMet}.
|
||||
*
|
||||
* We are moving the *evaluation* of stateful conditions to core (see
|
||||
* https://github.com/home-assistant/frontend/issues/52836). The visibility
|
||||
* format therefore becomes the union of:
|
||||
*
|
||||
* - the **client-only** lovelace conditions that have no usable core
|
||||
* equivalent for dashboards — `screen`, `user`, `view_columns`, `location`,
|
||||
* and `time` (evaluated against the viewer's local context); and
|
||||
* - any **core** automation condition (`state`, `numeric_state`, `template`,
|
||||
* `sun`, `zone`, `device`, and integration-provided conditions), which is
|
||||
* evaluated server-side through `subscribe_condition`.
|
||||
*
|
||||
* The two may be mixed freely, including inside `and` / `or` / `not`.
|
||||
*
|
||||
* Back-compat is **read both / write new**: existing dashboards keep their
|
||||
* lovelace-format `state` / `numeric_state` conditions (`entity`, `state_not`,
|
||||
* …) and are translated to core format on the fly (see
|
||||
* `common/condition/translate.ts`); only conditions the user edits and saves
|
||||
* are persisted in core format.
|
||||
*
|
||||
* Note: lovelace `state` / `numeric_state` use `entity`, while their core
|
||||
* counterparts use `entity_id`. Both shapes coexist in this union and are
|
||||
* disambiguated by that field — centralized in `common/condition/translate.ts`.
|
||||
*/
|
||||
export type VisibilityCondition =
|
||||
// Client-only lovelace conditions (no core equivalent for dashboards)
|
||||
| ScreenCondition
|
||||
| UserCondition
|
||||
| ViewColumnsCondition
|
||||
| LocationCondition
|
||||
| TimeCondition
|
||||
// Lovelace stateful conditions (read-both back-compat; `entity`-based)
|
||||
| StateCondition
|
||||
| NumericStateCondition
|
||||
| LegacyCondition
|
||||
// Core automation conditions (server-evaluated; `entity_id`-based)
|
||||
| CoreVisibilityCondition
|
||||
// Logical combinators over the mixed union
|
||||
| VisibilityLogicalCondition;
|
||||
|
||||
/**
|
||||
* Core automation conditions usable for dashboard visibility, evaluated
|
||||
* server-side. Mirrors `data/automation`'s condition types, minus the ones
|
||||
* kept client-side by decision (`time`) and the ones with no dashboard meaning
|
||||
* (`trigger`). The `PlatformCondition` member covers integration-provided
|
||||
* conditions and, being a `condition: string` catch-all, also subsumes the
|
||||
* already-core `state` / `numeric_state` shapes.
|
||||
*/
|
||||
export type CoreVisibilityCondition =
|
||||
| CoreStateCondition
|
||||
| CoreNumericStateCondition
|
||||
| SunCondition
|
||||
| ZoneCondition
|
||||
| TemplateCondition
|
||||
| DeviceCondition
|
||||
| CorePlatformCondition;
|
||||
|
||||
/** `and` / `or` / `not` combinator whose children are the mixed union. */
|
||||
export interface VisibilityLogicalCondition extends BaseCondition {
|
||||
condition: "and" | "or" | "not";
|
||||
conditions?: VisibilityCondition[];
|
||||
}
|
||||
|
||||
function getValueFromEntityId(
|
||||
hass: HomeAssistant,
|
||||
value: string
|
||||
@@ -114,7 +194,15 @@ function checkStateCondition(
|
||||
hass: HomeAssistant,
|
||||
context: ConditionContext
|
||||
) {
|
||||
const entityId = condition.entity || context.entity_id;
|
||||
// A core-format condition carries its own `entity_id`; prefer it over the
|
||||
// lovelace `entity` and the host's context entity so the optimistic seed
|
||||
// targets the same entity the server-side subscription does.
|
||||
const entityId =
|
||||
("entity_id" in condition
|
||||
? (condition as { entity_id?: string }).entity_id
|
||||
: undefined) ||
|
||||
condition.entity ||
|
||||
context.entity_id;
|
||||
const stateObj = entityId ? hass.states[entityId] : undefined;
|
||||
const attribute = "attribute" in condition ? condition.attribute : undefined;
|
||||
let state: string;
|
||||
@@ -157,7 +245,14 @@ function checkStateNumericCondition(
|
||||
hass: HomeAssistant,
|
||||
context: ConditionContext
|
||||
) {
|
||||
const entityId = condition.entity || context.entity_id;
|
||||
// See checkStateCondition: prefer a core-format `entity_id` over the lovelace
|
||||
// `entity` and the host's context entity.
|
||||
const entityId =
|
||||
("entity_id" in condition
|
||||
? (condition as { entity_id?: string }).entity_id
|
||||
: undefined) ||
|
||||
condition.entity ||
|
||||
context.entity_id;
|
||||
const stateObj = entityId ? hass.states[entityId] : undefined;
|
||||
const state = condition.attribute
|
||||
? stateObj?.attributes[condition.attribute]
|
||||
@@ -432,6 +527,8 @@ export function validateConditionalConfig(
|
||||
return validateLocationCondition(c);
|
||||
case "numeric_state":
|
||||
return validateNumericStateCondition(c);
|
||||
case "state":
|
||||
return validateStateCondition(c);
|
||||
case "and":
|
||||
return validateAndCondition(c);
|
||||
case "not":
|
||||
@@ -439,7 +536,9 @@ export function validateConditionalConfig(
|
||||
case "or":
|
||||
return validateOrCondition(c);
|
||||
default:
|
||||
return validateStateCondition(c);
|
||||
// Server-evaluated conditions (template, sun, zone, device, and
|
||||
// integration-provided types) are validated by core, not the client.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return validateStateCondition(c);
|
||||
@@ -466,8 +565,12 @@ export function addEntityToCondition(
|
||||
}
|
||||
|
||||
if (
|
||||
condition.condition === "state" ||
|
||||
condition.condition === "numeric_state"
|
||||
(condition.condition === "state" ||
|
||||
condition.condition === "numeric_state") &&
|
||||
// A core-format condition already targets its own `entity_id`; do not graft
|
||||
// the host's context entity onto it (that would both mis-evaluate and emit a
|
||||
// schema-invalid core condition carrying both `entity` and `entity_id`).
|
||||
!("entity_id" in condition)
|
||||
) {
|
||||
return {
|
||||
entity: entityId,
|
||||
|
||||
@@ -5,11 +5,8 @@ import type { HomeAssistant } from "../../../types";
|
||||
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||
import type { HuiCard } from "../cards/hui-card";
|
||||
import type { ConditionalCardConfig } from "../cards/types";
|
||||
import type { Condition } from "../common/validate-condition";
|
||||
import {
|
||||
checkConditionsMet,
|
||||
validateConditionalConfig,
|
||||
} from "../common/validate-condition";
|
||||
import type { VisibilityCondition } from "../common/validate-condition";
|
||||
import { validateConditionalConfig } from "../common/validate-condition";
|
||||
import type { ConditionalRowConfig, LovelaceRow } from "../entity-rows/types";
|
||||
|
||||
declare global {
|
||||
@@ -26,6 +23,13 @@ export class HuiConditionalBase extends ConditionalListenerMixin<
|
||||
|
||||
@property({ type: Boolean }) public preview = false;
|
||||
|
||||
// Stay mounted while hidden so the evaluator keeps its subscriptions alive and
|
||||
// can report a server-evaluated condition flipping to visible. Otherwise the
|
||||
// wrapper (hui-card) removes the hidden conditional card from the DOM, tearing
|
||||
// the evaluator down; the synchronous seed can revive a client condition but
|
||||
// not a server one (template/sun/…), so it would never reappear.
|
||||
public connectedWhileHidden = true;
|
||||
|
||||
@state() protected _config?: ConditionalCardConfig | ConditionalRowConfig;
|
||||
|
||||
protected _element?: HuiCard | LovelaceRow;
|
||||
@@ -66,17 +70,15 @@ export class HuiConditionalBase extends ConditionalListenerMixin<
|
||||
}
|
||||
|
||||
protected setupConditionalListeners() {
|
||||
if (!this._config || !this.hass) {
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter to supported conditions (those with 'condition' property)
|
||||
const supportedConditions = this._config.conditions.filter(
|
||||
(c) => "condition" in c
|
||||
) as Condition[];
|
||||
|
||||
// Pass filtered conditions to parent implementation
|
||||
super.setupConditionalListeners(supportedConditions);
|
||||
// The evaluator handles every condition type, including legacy
|
||||
// `{ entity, state }` conditions, so feed them all through.
|
||||
super.setupConditionalListeners(
|
||||
this._config.conditions as VisibilityCondition[]
|
||||
);
|
||||
}
|
||||
|
||||
protected update(changed: PropertyValues): void {
|
||||
@@ -88,7 +90,6 @@ export class HuiConditionalBase extends ConditionalListenerMixin<
|
||||
changed.has("hass") ||
|
||||
changed.has("preview")
|
||||
) {
|
||||
this.clearConditionalListeners();
|
||||
this.setupConditionalListeners();
|
||||
this._updateVisibility();
|
||||
}
|
||||
@@ -101,13 +102,7 @@ export class HuiConditionalBase extends ConditionalListenerMixin<
|
||||
|
||||
this._element.preview = this.preview;
|
||||
|
||||
const conditionMet =
|
||||
conditionsMet ??
|
||||
checkConditionsMet(
|
||||
this._config.conditions,
|
||||
this.hass,
|
||||
this._conditionContext
|
||||
);
|
||||
const conditionMet = conditionsMet ?? this._conditionsVisible();
|
||||
|
||||
this.setVisibility(conditionMet);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import deepClone from "deep-clone-simple";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { ConditionListenersController } from "../../../../common/controllers/condition-listeners-controller";
|
||||
import { isPureClientCondition } from "../../../../common/condition/translate";
|
||||
import type { ConditionEvaluation } from "../../../../common/controllers/condition-evaluator-controller";
|
||||
import { ConditionEvaluatorController } from "../../../../common/controllers/condition-evaluator-controller";
|
||||
import { storage } from "../../../../common/decorators/storage";
|
||||
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
@@ -32,19 +34,33 @@ import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-tooltip";
|
||||
import "../../../../components/ha-yaml-editor";
|
||||
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../config/automation/condition/ha-automation-condition-editor";
|
||||
import "../../../config/automation/condition/types/ha-automation-condition-device";
|
||||
import "../../../config/automation/condition/types/ha-automation-condition-numeric_state";
|
||||
import "../../../config/automation/condition/types/ha-automation-condition-state";
|
||||
import "../../../config/automation/condition/types/ha-automation-condition-sun";
|
||||
import "../../../config/automation/condition/types/ha-automation-condition-template";
|
||||
import "../../../config/automation/condition/types/ha-automation-condition-zone";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type {
|
||||
NumericStateCondition as CoreNumericStateCondition,
|
||||
StateCondition as CoreStateCondition,
|
||||
} from "../../../../data/automation";
|
||||
import { ICON_CONDITION } from "../../common/icon-condition";
|
||||
import type {
|
||||
AndCondition,
|
||||
Condition,
|
||||
ConditionContext,
|
||||
LegacyCondition,
|
||||
NotCondition,
|
||||
NumericStateCondition,
|
||||
OrCondition,
|
||||
StateCondition,
|
||||
VisibilityCondition,
|
||||
} from "../../common/validate-condition";
|
||||
import {
|
||||
checkConditionsMet,
|
||||
addEntityToCondition,
|
||||
validateConditionalConfig,
|
||||
} from "../../common/validate-condition";
|
||||
import type { ConditionsEntityContext } from "./context";
|
||||
@@ -72,14 +88,98 @@ const containsNoEntityCondition = (
|
||||
noEntity &&
|
||||
CONTAINER_CONDITIONS.includes(condition.condition) &&
|
||||
(condition as OrCondition | AndCondition | NotCondition).conditions?.some(
|
||||
(c) => NO_ENTITY_CONDITIONS.includes(c.condition)
|
||||
(c) =>
|
||||
NO_ENTITY_CONDITIONS.includes(c.condition) ||
|
||||
containsNoEntityCondition(c, noEntity)
|
||||
) === true;
|
||||
|
||||
// Server-class condition types with no lovelace editor; edited via the
|
||||
// automation condition editors (which already speak core format).
|
||||
export const SERVER_EDITOR_CONDITIONS = ["template", "sun", "zone", "device"];
|
||||
|
||||
export const isServerEditorCondition = (condition: string): boolean =>
|
||||
SERVER_EDITOR_CONDITIONS.includes(condition);
|
||||
|
||||
// Condition types edited via the core automation condition editors. The
|
||||
// server-class types always are; `state` / `numeric_state` are too, except in
|
||||
// entity-filter mode, where they keep the lovelace no-entity syntax and editor.
|
||||
export const usesAutomationConditionEditor = (
|
||||
conditionType: string,
|
||||
noEntity: boolean
|
||||
): boolean =>
|
||||
isServerEditorCondition(conditionType) ||
|
||||
(!noEntity &&
|
||||
(conditionType === "state" || conditionType === "numeric_state"));
|
||||
|
||||
// Render-only translation: present a lovelace `state` / `numeric_state`
|
||||
// condition in the struct-valid core format the automation editor speaks. This
|
||||
// is edit-faithful — unlike the eval-oriented `translateToCoreCondition`, it
|
||||
// never collapses an incomplete config to always-false. Already-core conditions
|
||||
// (carrying `entity_id`) and every other type pass through unchanged. When the
|
||||
// lovelace condition is entity-less (it implicitly targets the host card's
|
||||
// entity), `contextEntityId` is folded in as the `entity_id` so the automation
|
||||
// editor shows the effective entity instead of an empty, invalid field.
|
||||
const toCoreEditorCondition = (
|
||||
condition: VisibilityCondition,
|
||||
contextEntityId?: string
|
||||
): VisibilityCondition => {
|
||||
if ("entity_id" in condition) {
|
||||
return condition;
|
||||
}
|
||||
// Legacy `{ entity, state }` has no `condition` key and is treated as `state`.
|
||||
if (!("condition" in condition) || condition.condition === "state") {
|
||||
const lovelace = condition as StateCondition | LegacyCondition;
|
||||
const attribute = "attribute" in lovelace ? lovelace.attribute : undefined;
|
||||
const entity_id = lovelace.entity ?? contextEntityId ?? "";
|
||||
// Core has no `state_not`; represent it as `not(state)`, which routes to
|
||||
// the (lovelace) `not` editor wrapping a core `state` editor.
|
||||
if (lovelace.state === undefined && lovelace.state_not !== undefined) {
|
||||
const inner: CoreStateCondition = {
|
||||
condition: "state",
|
||||
entity_id,
|
||||
state: lovelace.state_not,
|
||||
};
|
||||
if (attribute !== undefined) {
|
||||
inner.attribute = attribute;
|
||||
}
|
||||
return { condition: "not", conditions: [inner] };
|
||||
}
|
||||
// Incomplete configs keep an empty `state` so the editor stays usable.
|
||||
const core: CoreStateCondition = {
|
||||
condition: "state",
|
||||
entity_id,
|
||||
state: lovelace.state ?? [],
|
||||
};
|
||||
if (attribute !== undefined) {
|
||||
core.attribute = attribute;
|
||||
}
|
||||
return core;
|
||||
}
|
||||
if (condition.condition === "numeric_state") {
|
||||
const lovelace = condition as NumericStateCondition;
|
||||
const core: CoreNumericStateCondition = {
|
||||
condition: "numeric_state",
|
||||
entity_id: lovelace.entity ?? contextEntityId ?? "",
|
||||
};
|
||||
if (lovelace.attribute !== undefined) {
|
||||
core.attribute = lovelace.attribute;
|
||||
}
|
||||
if (lovelace.above !== undefined) {
|
||||
core.above = lovelace.above;
|
||||
}
|
||||
if (lovelace.below !== undefined) {
|
||||
core.below = lovelace.below;
|
||||
}
|
||||
return core;
|
||||
}
|
||||
return condition;
|
||||
};
|
||||
|
||||
@customElement("ha-card-condition-editor")
|
||||
export class HaCardConditionEditor extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) condition!: Condition | LegacyCondition;
|
||||
@property({ attribute: false }) condition!: VisibilityCondition;
|
||||
|
||||
@state()
|
||||
@consume({ context: conditionsEntityContext, subscribe: true })
|
||||
@@ -95,7 +195,7 @@ export class HaCardConditionEditor extends LitElement {
|
||||
subscribe: false,
|
||||
storage: "sessionStorage",
|
||||
})
|
||||
protected _clipboard?: Condition | LegacyCondition;
|
||||
protected _clipboard?: VisibilityCondition;
|
||||
|
||||
@state() public _yamlMode = false;
|
||||
|
||||
@@ -112,7 +212,31 @@ export class HaCardConditionEditor extends LitElement {
|
||||
message?: string;
|
||||
} = { state: "unknown" };
|
||||
|
||||
private _listeners = new ConditionListenersController(this);
|
||||
// Live-test indicator, driven by the same server-backed evaluator the
|
||||
// dashboard uses at runtime: client leaves locally, server-class subtrees via
|
||||
// `subscribe_condition`, combined with three-valued logic.
|
||||
private _conditionEvaluator = new ConditionEvaluatorController(this, {
|
||||
// Debounce so editing (e.g. typing a template) doesn't churn subscriptions.
|
||||
resubscribeDelay: 500,
|
||||
onResult: (result, error) => this._setLiveTestResult(result, error),
|
||||
});
|
||||
|
||||
// Cache of the folded observation (and its client-validity) keyed by the
|
||||
// source condition + entity context, so the evaluator's reference-based
|
||||
// signature memo keeps hitting on hass-only ticks instead of rebuilding the
|
||||
// array — mirrors ConditionalListenerMixin.
|
||||
private __observedSource?: VisibilityCondition;
|
||||
|
||||
private __observedEntityId?: string;
|
||||
|
||||
private __observed?: VisibilityCondition[];
|
||||
|
||||
private __clientInvalid = false;
|
||||
|
||||
// Pins the live-test result for the hidden / client-invalid branches that
|
||||
// bypass the evaluator, so its torn-down `unknown` callback can't clobber
|
||||
// them — mirrors ha-visibility-status.
|
||||
private _override?: LiveTestState;
|
||||
|
||||
private get _editor() {
|
||||
if (!this._condition) return undefined;
|
||||
@@ -121,82 +245,141 @@ export class HaCardConditionEditor extends LitElement {
|
||||
) as LovelaceConditionEditorConstructor | undefined;
|
||||
}
|
||||
|
||||
private get _usesAutomationEditor(): boolean {
|
||||
return (
|
||||
!!this._condition &&
|
||||
usesAutomationConditionEditor(this._condition.condition, this._noEntity)
|
||||
);
|
||||
}
|
||||
|
||||
// No-entity (filter-mode) conditions have no entity to evaluate against, so
|
||||
// the live-test indicator is suppressed for those.
|
||||
private _hideLiveTest(condition: Condition): boolean {
|
||||
return (
|
||||
isNoEntityCondition(condition.condition, this._noEntity) ||
|
||||
containsNoEntityCondition(condition, this._noEntity)
|
||||
);
|
||||
}
|
||||
|
||||
public expand() {
|
||||
this.updateComplete.then(() => {
|
||||
this.shadowRoot!.querySelector("ha-expansion-panel")!.expanded = true;
|
||||
});
|
||||
}
|
||||
|
||||
private _setupConditionListeners() {
|
||||
this._listeners.setup(
|
||||
this.condition ? [this.condition as Condition] : [],
|
||||
this.hass,
|
||||
() => this._evaluateLiveTest()
|
||||
);
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
if (changedProperties.has("condition")) {
|
||||
this._condition = {
|
||||
// Recompute on entity-context change too: an entity-less condition folds in
|
||||
// the host card's entity, which arrives via context (possibly after the
|
||||
// condition is first set).
|
||||
if (
|
||||
changedProperties.has("condition") ||
|
||||
(changedProperties as Map<string, unknown>).has("_entityContext")
|
||||
) {
|
||||
const normalized = {
|
||||
condition: "state",
|
||||
...this.condition,
|
||||
};
|
||||
const validator = this._editor?.validateUIConfig;
|
||||
if (validator) {
|
||||
try {
|
||||
validator(this._condition, this.hass);
|
||||
this._uiAvailable = true;
|
||||
this._uiWarnings = [];
|
||||
} catch (err) {
|
||||
this._uiWarnings = handleStructError(
|
||||
this.hass,
|
||||
err as Error
|
||||
).warnings;
|
||||
this._uiAvailable = false;
|
||||
}
|
||||
} else {
|
||||
this._uiAvailable = false;
|
||||
} as Condition;
|
||||
// In "current" mode the card supplies the entity for entity-less
|
||||
// conditions; fold it into the displayed core condition.
|
||||
const contextEntityId =
|
||||
this._entityContext?.mode === "current"
|
||||
? this._entityContext.entityId
|
||||
: undefined;
|
||||
// Present lovelace `state` / `numeric_state` in core format for the
|
||||
// automation editor (read-both back-compat); every other type passes
|
||||
// through unchanged. `_condition` always carries a `condition` key (core
|
||||
// entries coexist as the wider runtime shape, narrowed here for display).
|
||||
this._condition = (
|
||||
usesAutomationConditionEditor(normalized.condition, this._noEntity)
|
||||
? toCoreEditorCondition(normalized, contextEntityId)
|
||||
: normalized
|
||||
) as Condition;
|
||||
if (this._usesAutomationEditor) {
|
||||
// Rendered by the embedded automation condition editor, which provides
|
||||
// its own UI for these core-format types.
|
||||
this._uiAvailable = true;
|
||||
this._uiWarnings = [];
|
||||
} else {
|
||||
const validator = this._editor?.validateUIConfig;
|
||||
if (validator) {
|
||||
try {
|
||||
validator(this._condition, this.hass);
|
||||
this._uiAvailable = true;
|
||||
this._uiWarnings = [];
|
||||
} catch (err) {
|
||||
this._uiWarnings = handleStructError(
|
||||
this.hass,
|
||||
err as Error
|
||||
).warnings;
|
||||
this._uiAvailable = false;
|
||||
}
|
||||
} else {
|
||||
this._uiAvailable = false;
|
||||
this._uiWarnings = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!this._uiAvailable && !this._yamlMode) {
|
||||
this._yamlMode = true;
|
||||
}
|
||||
|
||||
this._setupConditionListeners();
|
||||
}
|
||||
|
||||
if (changedProperties.has("condition") || changedProperties.has("hass")) {
|
||||
this._evaluateLiveTest();
|
||||
this._updateLiveTest();
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues<this>): void {
|
||||
if ((changedProperties as Map<string, unknown>).has("_entityContext")) {
|
||||
this._evaluateLiveTest();
|
||||
this._updateLiveTest();
|
||||
}
|
||||
}
|
||||
|
||||
private _evaluateLiveTest() {
|
||||
if (!this.condition || !this._condition) {
|
||||
private _liveTestContext(): ConditionContext {
|
||||
return this._entityContext?.mode === "current"
|
||||
? { entity_id: this._entityContext.entityId }
|
||||
: {};
|
||||
}
|
||||
|
||||
// Feed the condition (with the card's entity folded in when in "current"
|
||||
// mode) to the evaluator, which subscribes server subtrees and evaluates
|
||||
// client leaves locally. `onResult` maps its verdict to the indicator.
|
||||
private _updateLiveTest() {
|
||||
if (
|
||||
!this.condition ||
|
||||
!this._condition ||
|
||||
this._hideLiveTest(this._condition)
|
||||
) {
|
||||
this._override = "unknown";
|
||||
this._conditionEvaluator.observe(undefined, this.hass);
|
||||
this._liveTestResult = { state: "unknown" };
|
||||
return;
|
||||
}
|
||||
|
||||
const entityId = this._liveTestContext().entity_id;
|
||||
// Rebuild the folded observation + client-validity only when the source
|
||||
// condition or entity context changes, so a fresh array isn't fed to the
|
||||
// evaluator on every hass tick (which would defeat its signature memo).
|
||||
if (
|
||||
isNoEntityCondition(this._condition.condition, this._noEntity) ||
|
||||
containsNoEntityCondition(this._condition, this._noEntity)
|
||||
this.condition !== this.__observedSource ||
|
||||
entityId !== this.__observedEntityId
|
||||
) {
|
||||
this._liveTestResult = {
|
||||
state: "unknown",
|
||||
message: this.hass.localize(
|
||||
"ui.panel.lovelace.editor.condition-editor.live_test_state.unknown"
|
||||
),
|
||||
};
|
||||
return;
|
||||
this.__observedSource = this.condition;
|
||||
this.__observedEntityId = entityId;
|
||||
this.__clientInvalid =
|
||||
isPureClientCondition(this.condition) &&
|
||||
!validateConditionalConfig([this.condition] as Condition[]);
|
||||
const observed = entityId
|
||||
? addEntityToCondition(this.condition as Condition, entityId)
|
||||
: this.condition;
|
||||
this.__observed = [observed] as VisibilityCondition[];
|
||||
}
|
||||
|
||||
if (!validateConditionalConfig([this.condition])) {
|
||||
// The server-backed path only reports errors for server-class subtrees, so
|
||||
// surface a malformed client-only config as `invalid` here.
|
||||
if (this.__clientInvalid) {
|
||||
this._override = "invalid";
|
||||
this._conditionEvaluator.observe(undefined, this.hass);
|
||||
this._liveTestResult = {
|
||||
state: "invalid",
|
||||
message: this.hass.localize(
|
||||
@@ -206,15 +389,32 @@ export class HaCardConditionEditor extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const testContext =
|
||||
this._entityContext?.mode === "current"
|
||||
? { entity_id: this._entityContext.entityId }
|
||||
: {};
|
||||
const pass = checkConditionsMet([this.condition], this.hass, testContext);
|
||||
this._override = undefined;
|
||||
this._conditionEvaluator.observe(this.__observed, this.hass, () =>
|
||||
this._liveTestContext()
|
||||
);
|
||||
}
|
||||
|
||||
private _setLiveTestResult(result: ConditionEvaluation, error?: string) {
|
||||
// The hidden / client-invalid branches pin the result; ignore the
|
||||
// evaluator's (torn-down) callback in those cases — mirrors
|
||||
// ha-visibility-status.
|
||||
if (this._override !== undefined) {
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
// Surface the raw server error as the tooltip detail (the localized
|
||||
// `invalid` label remains the indicator's aria-label) — matches how the
|
||||
// automation condition editor reports validation/test errors.
|
||||
this._liveTestResult = { state: "invalid", message: error };
|
||||
return;
|
||||
}
|
||||
const liveState: LiveTestState =
|
||||
result === "visible" ? "pass" : result === "hidden" ? "fail" : "unknown";
|
||||
this._liveTestResult = {
|
||||
state: pass ? "pass" : "fail",
|
||||
state: liveState,
|
||||
message: this.hass.localize(
|
||||
`ui.panel.lovelace.editor.condition-editor.live_test_state.${pass ? "pass" : "fail"}`
|
||||
`ui.panel.lovelace.editor.condition-editor.live_test_state.${liveState}`
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -224,9 +424,7 @@ export class HaCardConditionEditor extends LitElement {
|
||||
|
||||
if (!condition) return nothing;
|
||||
|
||||
const hideLiveTest =
|
||||
isNoEntityCondition(condition.condition, this._noEntity) ||
|
||||
containsNoEntityCondition(condition, this._noEntity);
|
||||
const hideLiveTest = this._hideLiveTest(condition);
|
||||
|
||||
return html`
|
||||
<div class="container">
|
||||
@@ -286,8 +484,7 @@ export class HaCardConditionEditor extends LitElement {
|
||||
>
|
||||
</ha-icon-button>
|
||||
|
||||
${isNoEntityCondition(condition.condition, this._noEntity) ||
|
||||
containsNoEntityCondition(condition, this._noEntity)
|
||||
${hideLiveTest
|
||||
? nothing
|
||||
: html`<ha-dropdown-item value="test">
|
||||
${this.hass.localize(
|
||||
@@ -365,22 +562,33 @@ export class HaCardConditionEditor extends LitElement {
|
||||
@value-changed=${this._onYamlChange}
|
||||
></ha-yaml-editor>
|
||||
`
|
||||
: html`
|
||||
${dynamicElement(
|
||||
getConditionClassName(condition.condition, this._noEntity),
|
||||
{
|
||||
hass: this.hass,
|
||||
condition: condition,
|
||||
}
|
||||
)}
|
||||
`}
|
||||
: this._usesAutomationEditor
|
||||
? html`
|
||||
<ha-automation-condition-editor
|
||||
.hass=${this.hass}
|
||||
.condition=${condition}
|
||||
.uiSupported=${true}
|
||||
></ha-automation-condition-editor>
|
||||
`
|
||||
: html`
|
||||
${dynamicElement(
|
||||
getConditionClassName(
|
||||
condition.condition,
|
||||
this._noEntity
|
||||
),
|
||||
{
|
||||
hass: this.hass,
|
||||
condition: condition,
|
||||
}
|
||||
)}
|
||||
`}
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _handleAction(ev: HaDropdownSelectEvent) {
|
||||
private _handleAction(ev: HaDropdownSelectEvent) {
|
||||
const action = ev.detail.item.value;
|
||||
|
||||
if (action === undefined) {
|
||||
@@ -389,7 +597,7 @@ export class HaCardConditionEditor extends LitElement {
|
||||
|
||||
switch (action) {
|
||||
case "test":
|
||||
await this._testCondition();
|
||||
this._testCondition();
|
||||
return;
|
||||
case "duplicate":
|
||||
this._duplicateCondition();
|
||||
@@ -410,37 +618,20 @@ export class HaCardConditionEditor extends LitElement {
|
||||
|
||||
private _timeout?: number;
|
||||
|
||||
private async _testCondition() {
|
||||
private _testCondition() {
|
||||
if (this._timeout) {
|
||||
window.clearTimeout(this._timeout);
|
||||
this._timeout = undefined;
|
||||
}
|
||||
this._testingResult = undefined;
|
||||
const condition = this.condition;
|
||||
|
||||
const validateResult = validateConditionalConfig([this.condition]);
|
||||
|
||||
if (!validateResult) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.lovelace.editor.condition-editor.invalid_config_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.lovelace.editor.condition-editor.invalid_config_text"
|
||||
),
|
||||
});
|
||||
// Surface the evaluator's current live verdict as a transient chip. A
|
||||
// not-yet-reported (unknown) server result shows no chip rather than
|
||||
// asserting a false failure.
|
||||
const result = this._conditionEvaluator.result;
|
||||
if (result === "unknown") {
|
||||
this._testingResult = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const testContext =
|
||||
this._entityContext?.mode === "current"
|
||||
? { entity_id: this._entityContext.entityId }
|
||||
: {};
|
||||
this._testingResult = checkConditionsMet(
|
||||
[condition],
|
||||
this.hass,
|
||||
testContext
|
||||
);
|
||||
this._testingResult = result === "visible";
|
||||
|
||||
this._timeout = window.setTimeout(() => {
|
||||
this._testingResult = undefined;
|
||||
@@ -522,6 +713,6 @@ declare global {
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"duplicate-condition": { value: Condition | LegacyCondition };
|
||||
"duplicate-condition": { value: VisibilityCondition };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { HomeAssistant } from "../../../../types";
|
||||
import { ICON_CONDITION } from "../../common/icon-condition";
|
||||
import type {
|
||||
Condition,
|
||||
LegacyCondition,
|
||||
VisibilityCondition,
|
||||
} from "../../common/validate-condition";
|
||||
import type { ConditionsEntityContext } from "./context";
|
||||
import { conditionsEntityContext } from "./context";
|
||||
@@ -23,16 +23,15 @@ import "./ha-card-condition-editor";
|
||||
import {
|
||||
type HaCardConditionEditor,
|
||||
getConditionClassName,
|
||||
usesAutomationConditionEditor,
|
||||
} from "./ha-card-condition-editor";
|
||||
import type { LovelaceConditionEditorConstructor } from "./types";
|
||||
import "./types/ha-card-condition-and";
|
||||
import "./types/ha-card-condition-location";
|
||||
import "./types/ha-card-condition-not";
|
||||
import "./types/ha-card-condition-numeric_state";
|
||||
import "./types/ha-card-condition-numeric_state-no_entity";
|
||||
import "./types/ha-card-condition-or";
|
||||
import "./types/ha-card-condition-screen";
|
||||
import "./types/ha-card-condition-state";
|
||||
import "./types/ha-card-condition-state-no_entity";
|
||||
import "./types/ha-card-condition-time";
|
||||
import "./types/ha-card-condition-user";
|
||||
@@ -44,10 +43,15 @@ const UI_CONDITION = [
|
||||
"screen",
|
||||
"time",
|
||||
"user",
|
||||
// Server-class types, edited via the automation condition editors.
|
||||
"template",
|
||||
"sun",
|
||||
"zone",
|
||||
"device",
|
||||
"and",
|
||||
"not",
|
||||
"or",
|
||||
] as const satisfies readonly Condition["condition"][];
|
||||
] as const satisfies readonly string[];
|
||||
|
||||
@customElement("ha-card-conditions-editor")
|
||||
export class HaCardConditionsEditor extends LitElement {
|
||||
@@ -59,12 +63,9 @@ export class HaCardConditionsEditor extends LitElement {
|
||||
subscribe: false,
|
||||
storage: "sessionStorage",
|
||||
})
|
||||
protected _clipboard?: Condition | LegacyCondition;
|
||||
protected _clipboard?: VisibilityCondition;
|
||||
|
||||
@property({ attribute: false }) public conditions!: (
|
||||
| Condition
|
||||
| LegacyCondition
|
||||
)[];
|
||||
@property({ attribute: false }) public conditions!: VisibilityCondition[];
|
||||
|
||||
@state()
|
||||
@consume({ context: conditionsEntityContext, subscribe: true })
|
||||
@@ -77,6 +78,11 @@ export class HaCardConditionsEditor extends LitElement {
|
||||
private _focusLastConditionOnChange = false;
|
||||
|
||||
protected firstUpdated() {
|
||||
// The reused automation condition editors (state / numeric_state / template
|
||||
// / sun / zone / device) label their form fields from the `config`
|
||||
// translation fragment, which the dashboard editor does not otherwise load.
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
|
||||
// Expand the condition if there is only one
|
||||
if (this.conditions.length === 1) {
|
||||
const row = this.shadowRoot!.querySelector<HaCardConditionEditor>(
|
||||
@@ -161,17 +167,31 @@ export class HaCardConditionsEditor extends LitElement {
|
||||
}
|
||||
|
||||
private _addCondition(ev: HaDropdownSelectEvent) {
|
||||
const condition = ev.detail.item.value as "paste" | Condition["condition"];
|
||||
const value = ev.detail.item.value as string;
|
||||
const conditions = [...this.conditions];
|
||||
|
||||
if (!condition || (condition === "paste" && !this._clipboard)) {
|
||||
if (!value || (value === "paste" && !this._clipboard)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (condition === "paste") {
|
||||
if (value === "paste") {
|
||||
const newCondition = deepClone(this._clipboard!);
|
||||
conditions.push(newCondition);
|
||||
} else if (usesAutomationConditionEditor(value, this._noEntity)) {
|
||||
// Authored in core format via the automation condition editors (server
|
||||
// types, plus state/numeric_state outside entity-filter mode); seed with
|
||||
// that editor's default config.
|
||||
const elClass = customElements.get(`ha-automation-condition-${value}`) as
|
||||
| { defaultConfig?: object }
|
||||
| undefined;
|
||||
const defaultConfig = elClass?.defaultConfig;
|
||||
conditions.push(
|
||||
(defaultConfig
|
||||
? { ...defaultConfig }
|
||||
: { condition: value }) as VisibilityCondition
|
||||
);
|
||||
} else {
|
||||
const condition = value as Condition["condition"];
|
||||
const elClass = customElements.get(
|
||||
getConditionClassName(condition, this._noEntity)
|
||||
) as LovelaceConditionEditorConstructor | undefined;
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { mdiAlertCircle, mdiEye, mdiEyeOff } from "@mdi/js";
|
||||
import { mdiAlertCircle, mdiEye, mdiEyeOff, mdiHelpCircle } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { ConditionListenersController } from "../../../../common/controllers/condition-listeners-controller";
|
||||
import { isPureClientCondition } from "../../../../common/condition/translate";
|
||||
import type { ConditionEvaluation } from "../../../../common/controllers/condition-evaluator-controller";
|
||||
import { ConditionEvaluatorController } from "../../../../common/controllers/condition-evaluator-controller";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import { HaRowItem } from "../../../../components/item/ha-row-item";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type {
|
||||
Condition,
|
||||
LegacyCondition,
|
||||
ConditionContext,
|
||||
VisibilityCondition,
|
||||
} from "../../common/validate-condition";
|
||||
import {
|
||||
checkConditionsMet,
|
||||
addEntityToCondition,
|
||||
validateConditionalConfig,
|
||||
} from "../../common/validate-condition";
|
||||
import type { ConditionsEntityContext } from "./context";
|
||||
import { conditionsEntityContext } from "./context";
|
||||
|
||||
type VisibilityState = "visible" | "hidden" | "invalid";
|
||||
type VisibilityState = "visible" | "hidden" | "unknown" | "invalid";
|
||||
|
||||
const STATE_ICONS: Record<VisibilityState, string> = {
|
||||
visible: mdiEye,
|
||||
hidden: mdiEyeOff,
|
||||
unknown: mdiHelpCircle,
|
||||
invalid: mdiAlertCircle,
|
||||
};
|
||||
|
||||
@@ -34,14 +38,14 @@ const STATE_ICONS: Record<VisibilityState, string> = {
|
||||
* Alert banner that surfaces the live visibility result for a set of
|
||||
* lovelace conditions.
|
||||
*
|
||||
* @attr {"visible"|"hidden"|"invalid"} state - Computed visibility state
|
||||
* @attr {"visible"|"hidden"|"unknown"|"invalid"} state - Computed visibility state
|
||||
*/
|
||||
@customElement("ha-visibility-status")
|
||||
export class HaVisibilityStatus extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public conditions: (Condition | LegacyCondition)[] = [];
|
||||
public conditions: VisibilityCondition[] = [];
|
||||
|
||||
@state()
|
||||
@consume({ context: conditionsEntityContext, subscribe: true })
|
||||
@@ -50,17 +54,30 @@ export class HaVisibilityStatus extends LitElement {
|
||||
@property()
|
||||
public state: VisibilityState = "visible";
|
||||
|
||||
private _listeners = new ConditionListenersController(this);
|
||||
// Evaluate the whole set through the same server-backed controller the
|
||||
// dashboard uses at runtime, so server-class conditions report a real
|
||||
// verdict instead of being flagged as an invalid configuration.
|
||||
private _conditionEvaluator = new ConditionEvaluatorController(this, {
|
||||
resubscribeDelay: 500,
|
||||
onResult: (result, error) => this._applyResult(result, error),
|
||||
});
|
||||
|
||||
// Cache the folded observation + client-validity keyed by (conditions ref,
|
||||
// entity id) so the controller's signature memo keeps hitting on hass-only
|
||||
// ticks. `_override` pins the state for the empty / client-invalid branches
|
||||
// that bypass the controller.
|
||||
private __observedSource?: VisibilityCondition[];
|
||||
|
||||
private __observedEntityId?: string;
|
||||
|
||||
private __observed?: VisibilityCondition[];
|
||||
|
||||
private __clientInvalid = false;
|
||||
|
||||
private _override?: VisibilityState;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProperties);
|
||||
if (changedProperties.has("conditions") || changedProperties.has("hass")) {
|
||||
this._listeners.setup(
|
||||
(this.conditions ?? []) as Condition[],
|
||||
this.hass,
|
||||
() => this._evaluate()
|
||||
);
|
||||
}
|
||||
if (
|
||||
changedProperties.has("hass") ||
|
||||
changedProperties.has("conditions") ||
|
||||
@@ -77,7 +94,9 @@ export class HaVisibilityStatus extends LitElement {
|
||||
? "success"
|
||||
: this.state === "hidden"
|
||||
? "warning"
|
||||
: "error"}
|
||||
: this.state === "unknown"
|
||||
? "info"
|
||||
: "error"}
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${STATE_ICONS[this.state]}></ha-svg-icon>
|
||||
<div class="headline">
|
||||
@@ -94,27 +113,76 @@ export class HaVisibilityStatus extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _context(): ConditionContext {
|
||||
return this._entityContext?.mode === "current"
|
||||
? { entity_id: this._entityContext.entityId }
|
||||
: {};
|
||||
}
|
||||
|
||||
private _evaluate() {
|
||||
const conditions = this.conditions ?? [];
|
||||
let newState: VisibilityState;
|
||||
|
||||
if (conditions.length === 0) {
|
||||
newState = "visible";
|
||||
} else if (!validateConditionalConfig(conditions)) {
|
||||
newState = "invalid";
|
||||
} else {
|
||||
const context =
|
||||
this._entityContext?.mode === "current"
|
||||
? { entity_id: this._entityContext.entityId }
|
||||
: {};
|
||||
newState = checkConditionsMet(conditions, this.hass, context)
|
||||
? "visible"
|
||||
: "hidden";
|
||||
}
|
||||
if (newState === this.state) {
|
||||
this._override = "visible";
|
||||
this._conditionEvaluator.observe(undefined, this.hass);
|
||||
this.state = "visible";
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = newState;
|
||||
const entityId =
|
||||
this._entityContext?.mode === "current"
|
||||
? this._entityContext.entityId
|
||||
: undefined;
|
||||
|
||||
// Rebuild the folded observation + client-validity only when the source
|
||||
// set or entity context changes, so a fresh array isn't fed to the
|
||||
// evaluator on every hass tick.
|
||||
if (
|
||||
conditions !== this.__observedSource ||
|
||||
entityId !== this.__observedEntityId
|
||||
) {
|
||||
this.__observedSource = conditions;
|
||||
this.__observedEntityId = entityId;
|
||||
this.__clientInvalid =
|
||||
conditions.every((c) => isPureClientCondition(c)) &&
|
||||
!validateConditionalConfig(conditions as Condition[]);
|
||||
this.__observed = (
|
||||
entityId
|
||||
? conditions.map((c) =>
|
||||
addEntityToCondition(c as Condition, entityId)
|
||||
)
|
||||
: conditions
|
||||
) as VisibilityCondition[];
|
||||
}
|
||||
|
||||
// `validateConditionalConfig` only understands client types; a malformed
|
||||
// server config surfaces through the controller's error instead.
|
||||
if (this.__clientInvalid) {
|
||||
this._override = "invalid";
|
||||
this._conditionEvaluator.observe(undefined, this.hass);
|
||||
this.state = "invalid";
|
||||
return;
|
||||
}
|
||||
|
||||
this._override = undefined;
|
||||
this._conditionEvaluator.observe(this.__observed, this.hass, () =>
|
||||
this._context()
|
||||
);
|
||||
}
|
||||
|
||||
private _applyResult(result: ConditionEvaluation, error?: string) {
|
||||
// The empty / client-invalid branches pin the state; ignore the
|
||||
// controller's (torn-down) result in those cases.
|
||||
if (this._override !== undefined) {
|
||||
return;
|
||||
}
|
||||
this.state = error
|
||||
? "invalid"
|
||||
: result === "visible"
|
||||
? "visible"
|
||||
: result === "hidden"
|
||||
? "hidden"
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { assert, literal, number, object, optional, string } from "superstruct";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
@@ -40,7 +40,9 @@ interface NumericStateConditionData {
|
||||
below?: number | string;
|
||||
}
|
||||
|
||||
@customElement("ha-card-condition-numeric_state")
|
||||
// Base class for the entity-filter (no-entity) numeric_state editor. The
|
||||
// with-entity dashboard editing path now uses the core automation condition
|
||||
// editor, so this class is not registered as an element on its own.
|
||||
export class HaCardConditionNumericState extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@@ -211,9 +213,3 @@ export class HaCardConditionNumericState extends LitElement {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-card-condition-numeric_state": HaCardConditionNumericState;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { assert, literal, object, optional, string } from "superstruct";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
@@ -37,7 +37,9 @@ interface StateConditionData {
|
||||
state?: string | string[];
|
||||
}
|
||||
|
||||
@customElement("ha-card-condition-state")
|
||||
// Base class for the entity-filter (no-entity) state editor. The with-entity
|
||||
// dashboard editing path now uses the core automation condition editor, so this
|
||||
// class is not registered as an element on its own.
|
||||
export class HaCardConditionState extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@@ -228,9 +230,3 @@ export class HaCardConditionState extends LitElement {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-card-condition-state": HaCardConditionState;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { customElement } from "lit/decorators";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||
import { createStyledHuiElement } from "../cards/picture-elements/create-styled-hui-element";
|
||||
import {
|
||||
checkConditionsMet,
|
||||
validateConditionalConfig,
|
||||
} from "../common/validate-condition";
|
||||
import type { VisibilityCondition } from "../common/validate-condition";
|
||||
import { validateConditionalConfig } from "../common/validate-condition";
|
||||
import type { LovelacePictureElementEditor } from "../types";
|
||||
import type {
|
||||
ConditionalElementConfig,
|
||||
@@ -13,18 +14,25 @@ import type {
|
||||
} from "./types";
|
||||
|
||||
@customElement("hui-conditional-element")
|
||||
class HuiConditionalElement extends HTMLElement implements LovelaceElement {
|
||||
class HuiConditionalElement
|
||||
extends ConditionalListenerMixin<ConditionalElementConfig>(ReactiveElement)
|
||||
implements LovelaceElement
|
||||
{
|
||||
public static async getConfigElement(): Promise<LovelacePictureElementEditor> {
|
||||
await import("../editor/config-elements/elements/hui-conditional-element-editor");
|
||||
return document.createElement("hui-conditional-element-editor");
|
||||
}
|
||||
|
||||
public _hass?: HomeAssistant;
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
private _config?: ConditionalElementConfig;
|
||||
@state() protected _config?: ConditionalElementConfig;
|
||||
|
||||
private _elements: LovelaceElement[] = [];
|
||||
|
||||
protected createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
public setConfig(config: ConditionalElementConfig): void {
|
||||
if (
|
||||
!config.conditions ||
|
||||
@@ -36,46 +44,58 @@ class HuiConditionalElement extends HTMLElement implements LovelaceElement {
|
||||
throw new Error("Invalid configuration");
|
||||
}
|
||||
|
||||
if (this._elements.length > 0) {
|
||||
this._elements.forEach((el: LovelaceElement) => {
|
||||
if (el.parentElement) {
|
||||
el.parentElement.removeChild(el);
|
||||
}
|
||||
});
|
||||
this._elements.forEach((el) => el.parentElement?.removeChild(el));
|
||||
this._elements = [];
|
||||
|
||||
this._elements = [];
|
||||
}
|
||||
|
||||
this._config = config;
|
||||
|
||||
this._config.elements.forEach((elementConfig: LovelaceElementConfig) => {
|
||||
config.elements.forEach((elementConfig: LovelaceElementConfig) => {
|
||||
this._elements.push(createStyledHuiElement(elementConfig));
|
||||
});
|
||||
|
||||
this._updateElements();
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
set hass(hass: HomeAssistant) {
|
||||
this._hass = hass;
|
||||
|
||||
this._updateElements();
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._updateVisibility();
|
||||
}
|
||||
|
||||
private _updateElements() {
|
||||
if (!this._hass || !this._config) {
|
||||
protected setupConditionalListeners() {
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visible = checkConditionsMet(this._config.conditions, this._hass, {});
|
||||
// The evaluator delegates the stateful conditions (state, numeric_state,
|
||||
// template, sun, zone, device, integration) to core and evaluates the
|
||||
// client-only ones locally, including legacy `{ entity, state }`.
|
||||
super.setupConditionalListeners(
|
||||
this._config.conditions as VisibilityCondition[]
|
||||
);
|
||||
}
|
||||
|
||||
this._elements.forEach((el: LovelaceElement) => {
|
||||
protected update(changed: PropertyValues): void {
|
||||
super.update(changed);
|
||||
|
||||
if (changed.has("_config") || changed.has("hass")) {
|
||||
this.setupConditionalListeners();
|
||||
this._updateVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
protected _updateVisibility() {
|
||||
if (!this.hass || !this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visible = this._conditionsVisible();
|
||||
|
||||
this._elements.forEach((el) => {
|
||||
if (visible) {
|
||||
el.hass = this._hass;
|
||||
el.hass = this.hass;
|
||||
if (!el.parentElement) {
|
||||
this.appendChild(el);
|
||||
}
|
||||
} else if (el.parentElement) {
|
||||
el.parentElement.removeChild(el);
|
||||
this.removeChild(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||
import { checkConditionsMet } from "../common/validate-condition";
|
||||
import { createHeadingBadgeElement } from "../create-element/create-heading-badge-element";
|
||||
import type { LovelaceHeadingBadge } from "../types";
|
||||
import type { LovelaceHeadingBadgeConfig } from "./types";
|
||||
@@ -160,14 +159,7 @@ export class HuiHeadingBadge extends ConditionalListenerMixin<LovelaceHeadingBad
|
||||
return;
|
||||
}
|
||||
|
||||
const visible =
|
||||
conditionsMet ??
|
||||
(!this.config?.visibility ||
|
||||
checkConditionsMet(
|
||||
this.config.visibility,
|
||||
this.hass,
|
||||
this._conditionContext
|
||||
));
|
||||
const visible = conditionsMet ?? this._conditionsVisible();
|
||||
this._setElementVisibility(visible);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import type { HomeAssistant } from "../../../types";
|
||||
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||
import "../cards/hui-card";
|
||||
import type { HuiCard } from "../cards/hui-card";
|
||||
import { checkConditionsMet } from "../common/validate-condition";
|
||||
import { createSectionElement } from "../create-element/create-section-element";
|
||||
import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog";
|
||||
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
|
||||
@@ -280,14 +279,7 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
|
||||
return;
|
||||
}
|
||||
|
||||
const visible =
|
||||
conditionsMet ??
|
||||
(!this._config.visibility ||
|
||||
checkConditionsMet(
|
||||
this._config.visibility,
|
||||
this.hass,
|
||||
this._conditionContext
|
||||
));
|
||||
const visible = conditionsMet ?? this._conditionsVisible();
|
||||
|
||||
if (!visible) {
|
||||
this._setElementVisibility(false);
|
||||
|
||||
@@ -6,7 +6,6 @@ import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import type { LovelaceViewSidebarConfig } from "../../../data/lovelace/config/view";
|
||||
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { checkConditionsMet } from "../common/validate-condition";
|
||||
import "../sections/hui-section";
|
||||
import type { Lovelace } from "../types";
|
||||
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
|
||||
@@ -37,14 +36,7 @@ export class HuiViewSidebar extends ConditionalListenerMixin<LovelaceViewSidebar
|
||||
protected _updateVisibility(conditionsMet?: boolean) {
|
||||
if (!this.hass || !this.config) return;
|
||||
|
||||
const visible =
|
||||
conditionsMet ??
|
||||
(!this.config.visibility ||
|
||||
checkConditionsMet(
|
||||
this.config.visibility,
|
||||
this.hass,
|
||||
this._conditionContext
|
||||
));
|
||||
const visible = conditionsMet ?? this._conditionsVisible();
|
||||
|
||||
if (visible !== this._visible) {
|
||||
this._visible = visible;
|
||||
|
||||
@@ -9310,13 +9310,15 @@
|
||||
"headline": "Current visibility: Hidden",
|
||||
"supporting": "Not all visibility conditions are met"
|
||||
},
|
||||
"unknown": {
|
||||
"headline": "Current visibility: Unknown",
|
||||
"supporting": "Waiting for the result of one or more conditions"
|
||||
},
|
||||
"invalid": {
|
||||
"headline": "Visibility status unknown",
|
||||
"headline": "Invalid configuration",
|
||||
"supporting": "One or more conditions have an invalid configuration"
|
||||
}
|
||||
},
|
||||
"invalid_config_title": "Invalid configuration",
|
||||
"invalid_config_text": "The condition cannot be tested because the configuration is not valid.",
|
||||
"condition": {
|
||||
"view_columns": {
|
||||
"label": "Number of columns",
|
||||
@@ -9370,6 +9372,18 @@
|
||||
},
|
||||
"and": {
|
||||
"label": "And"
|
||||
},
|
||||
"template": {
|
||||
"label": "Template"
|
||||
},
|
||||
"sun": {
|
||||
"label": "Sun"
|
||||
},
|
||||
"zone": {
|
||||
"label": "Zone"
|
||||
},
|
||||
"device": {
|
||||
"label": "Device"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
setupTimeListeners,
|
||||
setupMediaQueryListeners,
|
||||
} from "../../../src/common/condition/listeners";
|
||||
import { observeConditionChanges } from "../../../src/common/condition/listeners";
|
||||
import * as timeCalculator from "../../../src/common/condition/time-calculator";
|
||||
import type {
|
||||
TimeCondition,
|
||||
ScreenCondition,
|
||||
Condition,
|
||||
VisibilityCondition,
|
||||
} from "../../../src/panels/lovelace/common/validate-condition";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import * as mediaQuery from "../../../src/common/dom/media_query";
|
||||
@@ -15,15 +12,15 @@ import * as mediaQuery from "../../../src/common/dom/media_query";
|
||||
// Maximum delay for setTimeout (2^31 - 1 milliseconds, ~24.8 days)
|
||||
const MAX_TIMEOUT_DELAY = 2147483647;
|
||||
|
||||
describe("setupTimeListeners", () => {
|
||||
describe("observeConditionChanges – time boundaries", () => {
|
||||
let hass: HomeAssistant;
|
||||
let listeners: (() => void)[];
|
||||
let onUpdateCallback: (conditionsMet: boolean) => void;
|
||||
let onChangeCallback: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
listeners = [];
|
||||
onUpdateCallback = vi.fn();
|
||||
onChangeCallback = vi.fn();
|
||||
|
||||
hass = {
|
||||
locale: {
|
||||
@@ -56,11 +53,11 @@ describe("setupTimeListeners", () => {
|
||||
},
|
||||
];
|
||||
|
||||
setupTimeListeners(
|
||||
observeConditionChanges(
|
||||
conditions,
|
||||
hass,
|
||||
() => hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
onChangeCallback
|
||||
);
|
||||
|
||||
// Verify setTimeout was called with the capped delay
|
||||
@@ -70,7 +67,7 @@ describe("setupTimeListeners", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should not call onUpdate when hitting the cap", () => {
|
||||
it("should not call onChange when hitting the cap", () => {
|
||||
// Mock calculateNextTimeUpdate to return delays that decrease over time
|
||||
// Both first and second delays exceed the cap
|
||||
const delays = [
|
||||
@@ -91,33 +88,33 @@ describe("setupTimeListeners", () => {
|
||||
},
|
||||
];
|
||||
|
||||
setupTimeListeners(
|
||||
observeConditionChanges(
|
||||
conditions,
|
||||
hass,
|
||||
() => hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
onChangeCallback
|
||||
);
|
||||
|
||||
// Fast-forward to when the first timeout fires (at the cap)
|
||||
vi.advanceTimersByTime(MAX_TIMEOUT_DELAY);
|
||||
|
||||
// onUpdate should NOT have been called because we hit the cap
|
||||
expect(onUpdateCallback).not.toHaveBeenCalled();
|
||||
// onChange should NOT have been called because we hit the cap
|
||||
expect(onChangeCallback).not.toHaveBeenCalled();
|
||||
|
||||
// Fast-forward to the second timeout (still exceeds cap)
|
||||
vi.advanceTimersByTime(MAX_TIMEOUT_DELAY);
|
||||
|
||||
// Still should not have been called
|
||||
expect(onUpdateCallback).not.toHaveBeenCalled();
|
||||
expect(onChangeCallback).not.toHaveBeenCalled();
|
||||
|
||||
// Fast-forward to the third timeout (within cap)
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
// NOW onUpdate should have been called
|
||||
expect(onUpdateCallback).toHaveBeenCalledTimes(1);
|
||||
// NOW onChange should have been called
|
||||
expect(onChangeCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onUpdate normally when delay is within cap", () => {
|
||||
it("should call onChange normally when delay is within cap", () => {
|
||||
const normalDelay = 5000; // 5 seconds
|
||||
|
||||
vi.spyOn(timeCalculator, "calculateNextTimeUpdate").mockReturnValue(
|
||||
@@ -131,18 +128,18 @@ describe("setupTimeListeners", () => {
|
||||
},
|
||||
];
|
||||
|
||||
setupTimeListeners(
|
||||
observeConditionChanges(
|
||||
conditions,
|
||||
hass,
|
||||
() => hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
onChangeCallback
|
||||
);
|
||||
|
||||
// Fast-forward by the normal delay
|
||||
vi.advanceTimersByTime(normalDelay);
|
||||
|
||||
// onUpdate should have been called
|
||||
expect(onUpdateCallback).toHaveBeenCalledTimes(1);
|
||||
// onChange should have been called
|
||||
expect(onChangeCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should reschedule after hitting the cap", () => {
|
||||
@@ -163,11 +160,11 @@ describe("setupTimeListeners", () => {
|
||||
},
|
||||
];
|
||||
|
||||
setupTimeListeners(
|
||||
observeConditionChanges(
|
||||
conditions,
|
||||
hass,
|
||||
() => hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
onChangeCallback
|
||||
);
|
||||
|
||||
// First setTimeout call should use the capped delay
|
||||
@@ -208,11 +205,11 @@ describe("setupTimeListeners", () => {
|
||||
},
|
||||
];
|
||||
|
||||
setupTimeListeners(
|
||||
observeConditionChanges(
|
||||
conditions,
|
||||
hass,
|
||||
() => hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
onChangeCallback
|
||||
);
|
||||
|
||||
// Should have registered 2 cleanup functions (one per time condition)
|
||||
@@ -234,11 +231,11 @@ describe("setupTimeListeners", () => {
|
||||
},
|
||||
];
|
||||
|
||||
setupTimeListeners(
|
||||
observeConditionChanges(
|
||||
conditions,
|
||||
hass,
|
||||
() => hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
onChangeCallback
|
||||
);
|
||||
|
||||
// Call cleanup
|
||||
@@ -250,14 +247,14 @@ describe("setupTimeListeners", () => {
|
||||
});
|
||||
|
||||
describe("no time conditions", () => {
|
||||
it("should not setup listeners when no time conditions exist", () => {
|
||||
it("should not setup time listeners when no time conditions exist", () => {
|
||||
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
|
||||
|
||||
setupTimeListeners(
|
||||
observeConditionChanges(
|
||||
[],
|
||||
hass,
|
||||
() => hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
onChangeCallback
|
||||
);
|
||||
|
||||
// Should not have called setTimeout
|
||||
@@ -281,11 +278,11 @@ describe("setupTimeListeners", () => {
|
||||
},
|
||||
];
|
||||
|
||||
setupTimeListeners(
|
||||
observeConditionChanges(
|
||||
conditions,
|
||||
hass,
|
||||
() => hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
onChangeCallback
|
||||
);
|
||||
|
||||
// Should not have called setTimeout
|
||||
@@ -294,15 +291,15 @@ describe("setupTimeListeners", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupMediaQueryListeners", () => {
|
||||
describe("observeConditionChanges – media queries", () => {
|
||||
let hass: HomeAssistant;
|
||||
let listeners: (() => void)[];
|
||||
let onUpdateCallback: (conditionsMet: boolean) => void;
|
||||
let onChangeCallback: () => void;
|
||||
let listenMediaQuerySpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
listeners = [];
|
||||
onUpdateCallback = vi.fn();
|
||||
onChangeCallback = vi.fn();
|
||||
|
||||
hass = {
|
||||
locale: {
|
||||
@@ -313,6 +310,12 @@ describe("setupMediaQueryListeners", () => {
|
||||
},
|
||||
} as HomeAssistant;
|
||||
|
||||
// Stop time conditions (present in some mixed cases below) from scheduling
|
||||
// real timers — these tests only exercise the media-query path.
|
||||
vi.spyOn(timeCalculator, "calculateNextTimeUpdate").mockReturnValue(
|
||||
undefined
|
||||
);
|
||||
|
||||
// Mock matchMedia for screen condition checks
|
||||
global.matchMedia = vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
@@ -338,18 +341,18 @@ describe("setupMediaQueryListeners", () => {
|
||||
|
||||
describe("single media query", () => {
|
||||
it("should setup listener for single screen condition", () => {
|
||||
const conditions: Condition[] = [
|
||||
const conditions: VisibilityCondition[] = [
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(max-width: 600px)",
|
||||
} as ScreenCondition,
|
||||
];
|
||||
|
||||
setupMediaQueryListeners(
|
||||
observeConditionChanges(
|
||||
conditions,
|
||||
hass,
|
||||
() => hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
onChangeCallback
|
||||
);
|
||||
|
||||
expect(listenMediaQuerySpy).toHaveBeenCalledWith(
|
||||
@@ -359,8 +362,8 @@ describe("setupMediaQueryListeners", () => {
|
||||
expect(listeners).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should call onUpdate with matches value for single screen condition", () => {
|
||||
const conditions: Condition[] = [
|
||||
it("should call onChange when the media query fires", () => {
|
||||
const conditions: VisibilityCondition[] = [
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(max-width: 600px)",
|
||||
@@ -374,24 +377,25 @@ describe("setupMediaQueryListeners", () => {
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
setupMediaQueryListeners(
|
||||
observeConditionChanges(
|
||||
conditions,
|
||||
hass,
|
||||
() => hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
onChangeCallback
|
||||
);
|
||||
|
||||
// Simulate media query match
|
||||
capturedCallback?.(true);
|
||||
|
||||
// Should call onUpdate directly with the matches value
|
||||
expect(onUpdateCallback).toHaveBeenCalledWith(true);
|
||||
// observeConditionChanges only signals a possible change; the caller
|
||||
// recombines results, so onChange is invoked with no arguments.
|
||||
expect(onChangeCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple media queries", () => {
|
||||
it("should setup listeners for multiple screen conditions", () => {
|
||||
const conditions: Condition[] = [
|
||||
const conditions: VisibilityCondition[] = [
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(max-width: 600px)",
|
||||
@@ -402,11 +406,11 @@ describe("setupMediaQueryListeners", () => {
|
||||
} as ScreenCondition,
|
||||
];
|
||||
|
||||
setupMediaQueryListeners(
|
||||
observeConditionChanges(
|
||||
conditions,
|
||||
hass,
|
||||
() => hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
onChangeCallback
|
||||
);
|
||||
|
||||
expect(listenMediaQuerySpy).toHaveBeenCalledWith(
|
||||
@@ -420,8 +424,8 @@ describe("setupMediaQueryListeners", () => {
|
||||
expect(listeners).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should call onUpdate when media query changes with mixed conditions", () => {
|
||||
const conditions: Condition[] = [
|
||||
it("should call onChange when a media query changes with mixed conditions", () => {
|
||||
const conditions: VisibilityCondition[] = [
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(max-width: 600px)",
|
||||
@@ -439,47 +443,45 @@ describe("setupMediaQueryListeners", () => {
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
setupMediaQueryListeners(
|
||||
observeConditionChanges(
|
||||
conditions,
|
||||
hass,
|
||||
() => hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
onChangeCallback
|
||||
);
|
||||
|
||||
// Simulate media query change
|
||||
capturedCallback?.(true);
|
||||
|
||||
// Should call onUpdate (would check all conditions)
|
||||
expect(onUpdateCallback).toHaveBeenCalled();
|
||||
expect(onChangeCallback).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("no screen conditions", () => {
|
||||
it("should not setup listeners when no screen conditions exist", () => {
|
||||
const conditions: Condition[] = [
|
||||
it("should not setup media listeners when no screen conditions exist", () => {
|
||||
const conditions: VisibilityCondition[] = [
|
||||
{
|
||||
condition: "time",
|
||||
after: "08:00",
|
||||
} as TimeCondition,
|
||||
];
|
||||
|
||||
setupMediaQueryListeners(
|
||||
observeConditionChanges(
|
||||
conditions,
|
||||
hass,
|
||||
() => hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
onChangeCallback
|
||||
);
|
||||
|
||||
expect(listenMediaQuerySpy).not.toHaveBeenCalled();
|
||||
expect(listeners).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should handle empty conditions array", () => {
|
||||
setupMediaQueryListeners(
|
||||
observeConditionChanges(
|
||||
[],
|
||||
hass,
|
||||
() => hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
onChangeCallback
|
||||
);
|
||||
|
||||
expect(listenMediaQuerySpy).not.toHaveBeenCalled();
|
||||
@@ -493,18 +495,18 @@ describe("setupMediaQueryListeners", () => {
|
||||
|
||||
listenMediaQuerySpy.mockReturnValue(unsubFn);
|
||||
|
||||
const conditions: Condition[] = [
|
||||
const conditions: VisibilityCondition[] = [
|
||||
{
|
||||
condition: "screen",
|
||||
media_query: "(max-width: 600px)",
|
||||
} as ScreenCondition,
|
||||
];
|
||||
|
||||
setupMediaQueryListeners(
|
||||
observeConditionChanges(
|
||||
conditions,
|
||||
hass,
|
||||
() => hass,
|
||||
(unsub) => listeners.push(unsub),
|
||||
onUpdateCallback
|
||||
onChangeCallback
|
||||
);
|
||||
|
||||
expect(listeners).toHaveLength(1);
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ClientConditionEvaluator } from "../../../src/common/condition/split";
|
||||
import { splitConditionTree } from "../../../src/common/condition/split";
|
||||
import type { VisibilityCondition } from "../../../src/panels/lovelace/common/validate-condition";
|
||||
|
||||
const cond = (c: any): VisibilityCondition => c as VisibilityCondition;
|
||||
|
||||
/** Build a client evaluator backed by object-identity lookup. */
|
||||
const clientEvaluator =
|
||||
(
|
||||
results: Map<VisibilityCondition, boolean | undefined> = new Map()
|
||||
): ClientConditionEvaluator =>
|
||||
(c) =>
|
||||
results.get(c);
|
||||
|
||||
const noClient = clientEvaluator();
|
||||
|
||||
describe("splitConditionTree", () => {
|
||||
it("returns no subtrees for an all-client tree", () => {
|
||||
const screen = cond({
|
||||
condition: "screen",
|
||||
media_query: "(min-width: 1px)",
|
||||
});
|
||||
const time = cond({ condition: "time", after: "08:00" });
|
||||
const split = splitConditionTree([screen, time]);
|
||||
|
||||
expect(split.serverSubtrees).toHaveLength(0);
|
||||
expect(
|
||||
split.evaluate(
|
||||
clientEvaluator(
|
||||
new Map([
|
||||
[screen, true],
|
||||
[time, true],
|
||||
])
|
||||
),
|
||||
{}
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
split.evaluate(
|
||||
clientEvaluator(
|
||||
new Map([
|
||||
[screen, true],
|
||||
[time, false],
|
||||
])
|
||||
),
|
||||
{}
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("creates one subtree for a single server leaf and translates it", () => {
|
||||
const split = splitConditionTree([
|
||||
cond({ condition: "state", entity: "light.a", state: "on" }),
|
||||
]);
|
||||
|
||||
expect(split.serverSubtrees).toHaveLength(1);
|
||||
expect(split.serverSubtrees[0].coreCondition).toEqual({
|
||||
condition: "state",
|
||||
entity_id: "light.a",
|
||||
state: "on",
|
||||
});
|
||||
|
||||
expect(split.evaluate(noClient, { "0": true })).toBe(true);
|
||||
expect(split.evaluate(noClient, { "0": false })).toBe(false);
|
||||
// server result not reported yet
|
||||
expect(split.evaluate(noClient, {})).toBe(undefined);
|
||||
});
|
||||
|
||||
it("groups sibling server conditions under the implicit AND into one subscription", () => {
|
||||
const split = splitConditionTree([
|
||||
cond({ condition: "state", entity: "light.a", state: "on" }),
|
||||
cond({ condition: "template", value_template: "{{ true }}" }),
|
||||
]);
|
||||
|
||||
expect(split.serverSubtrees).toHaveLength(1);
|
||||
expect(split.serverSubtrees[0].coreCondition).toEqual({
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ condition: "state", entity_id: "light.a", state: "on" },
|
||||
{ condition: "template", value_template: "{{ true }}" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(split.evaluate(noClient, { "0": true })).toBe(true);
|
||||
expect(split.evaluate(noClient, { "0": false })).toBe(false);
|
||||
});
|
||||
|
||||
it("combines one server subtree with a client leaf via AND", () => {
|
||||
const screen = cond({ condition: "screen", media_query: "x" });
|
||||
const split = splitConditionTree([
|
||||
cond({ condition: "state", entity: "light.a", state: "on" }),
|
||||
screen,
|
||||
]);
|
||||
|
||||
expect(split.serverSubtrees).toHaveLength(1);
|
||||
|
||||
const withScreen = (v: boolean) => clientEvaluator(new Map([[screen, v]]));
|
||||
expect(split.evaluate(withScreen(true), { "0": true })).toBe(true);
|
||||
expect(split.evaluate(withScreen(false), { "0": true })).toBe(false);
|
||||
// false server result dominates even though the client leaf is unknown
|
||||
expect(split.evaluate(noClient, { "0": false })).toBe(false);
|
||||
// unknown server result with a passing client leaf → still unknown
|
||||
expect(split.evaluate(withScreen(true), {})).toBe(undefined);
|
||||
});
|
||||
|
||||
it("groups server siblings under a mixed OR using the or operator", () => {
|
||||
const screen = cond({ condition: "screen", media_query: "x" });
|
||||
const split = splitConditionTree([
|
||||
cond({
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ condition: "state", entity: "light.a", state: "on" },
|
||||
{ condition: "state", entity: "light.b", state: "on" },
|
||||
screen,
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(split.serverSubtrees).toHaveLength(1);
|
||||
expect(split.serverSubtrees[0].coreCondition).toEqual({
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ condition: "state", entity_id: "light.a", state: "on" },
|
||||
{ condition: "state", entity_id: "light.b", state: "on" },
|
||||
],
|
||||
});
|
||||
|
||||
const withScreen = (v: boolean) => clientEvaluator(new Map([[screen, v]]));
|
||||
// true server result dominates OR even though the client leaf is unknown
|
||||
expect(split.evaluate(noClient, { "0": true })).toBe(true);
|
||||
expect(split.evaluate(withScreen(true), { "0": false })).toBe(true);
|
||||
expect(split.evaluate(withScreen(false), { "0": false })).toBe(false);
|
||||
expect(split.evaluate(withScreen(false), {})).toBe(undefined);
|
||||
});
|
||||
|
||||
it("sends a fully-server not as a single core not subscription", () => {
|
||||
const split = splitConditionTree([
|
||||
cond({
|
||||
condition: "not",
|
||||
conditions: [
|
||||
{ condition: "state", entity: "light.a", state: "on" },
|
||||
{ condition: "state", entity: "light.b", state: "on" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(split.serverSubtrees).toHaveLength(1);
|
||||
expect(split.serverSubtrees[0].coreCondition).toEqual({
|
||||
condition: "not",
|
||||
conditions: [
|
||||
{
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ condition: "state", entity_id: "light.a", state: "on" },
|
||||
{ condition: "state", entity_id: "light.b", state: "on" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// negation handled server-side → just mirrors the subscription result
|
||||
expect(split.evaluate(noClient, { "0": true })).toBe(true);
|
||||
expect(split.evaluate(noClient, { "0": false })).toBe(false);
|
||||
});
|
||||
|
||||
it("negates a mixed not client-side using ¬(AND) semantics", () => {
|
||||
const screen = cond({ condition: "screen", media_query: "x" });
|
||||
const split = splitConditionTree([
|
||||
cond({
|
||||
condition: "not",
|
||||
conditions: [
|
||||
{ condition: "state", entity: "light.a", state: "on" },
|
||||
screen,
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(split.serverSubtrees).toHaveLength(1);
|
||||
expect(split.serverSubtrees[0].coreCondition).toEqual({
|
||||
condition: "state",
|
||||
entity_id: "light.a",
|
||||
state: "on",
|
||||
});
|
||||
|
||||
const withScreen = (v: boolean) => clientEvaluator(new Map([[screen, v]]));
|
||||
// ¬(server ∧ screen)
|
||||
expect(split.evaluate(withScreen(true), { "0": true })).toBe(false);
|
||||
expect(split.evaluate(withScreen(false), { "0": true })).toBe(true);
|
||||
expect(split.evaluate(withScreen(true), { "0": false })).toBe(true);
|
||||
// server unknown, screen passing → ¬(unknown) → unknown
|
||||
expect(split.evaluate(withScreen(true), {})).toBe(undefined);
|
||||
// server unknown but screen FALSE → ¬(unknown ∧ false) → ¬false → true
|
||||
// (false dominates AND even before the unknown is resolved)
|
||||
expect(split.evaluate(withScreen(false), {})).toBe(true);
|
||||
});
|
||||
|
||||
it("groups multiple server children of a mixed not and negates ¬(AND)", () => {
|
||||
const screen = cond({ condition: "screen", media_query: "x" });
|
||||
const split = splitConditionTree([
|
||||
cond({
|
||||
condition: "not",
|
||||
conditions: [
|
||||
{ condition: "state", entity: "light.a", state: "on" },
|
||||
{ condition: "state", entity: "light.b", state: "on" },
|
||||
screen,
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
// the two server siblings are grouped under `and` (the not's inner operator)
|
||||
expect(split.serverSubtrees).toHaveLength(1);
|
||||
expect(split.serverSubtrees[0].coreCondition).toEqual({
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ condition: "state", entity_id: "light.a", state: "on" },
|
||||
{ condition: "state", entity_id: "light.b", state: "on" },
|
||||
],
|
||||
});
|
||||
|
||||
const withScreen = (v: boolean) => clientEvaluator(new Map([[screen, v]]));
|
||||
// ¬(serverGroup ∧ screen)
|
||||
expect(split.evaluate(withScreen(true), { "0": true })).toBe(false);
|
||||
expect(split.evaluate(withScreen(false), { "0": true })).toBe(true);
|
||||
expect(split.evaluate(withScreen(true), { "0": false })).toBe(true);
|
||||
});
|
||||
|
||||
it("translates a fully-server empty not to a false-equivalent subscription", () => {
|
||||
const split = splitConditionTree([
|
||||
cond({ condition: "not", conditions: [] }),
|
||||
]);
|
||||
expect(split.serverSubtrees).toHaveLength(1);
|
||||
// ¬(AND of nothing) = ¬true = false, not core's bare not([]) = true
|
||||
expect(split.serverSubtrees[0].coreCondition).toEqual({
|
||||
condition: "not",
|
||||
conditions: [{ condition: "and", conditions: [] }],
|
||||
});
|
||||
});
|
||||
|
||||
it("handles deep nesting with multiple subscriptions", () => {
|
||||
const screen = cond({ condition: "screen", media_query: "x" });
|
||||
const split = splitConditionTree([
|
||||
cond({ condition: "state", entity: "light.a", state: "on" }),
|
||||
cond({
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ condition: "state", entity: "light.b", state: "on" },
|
||||
screen,
|
||||
],
|
||||
}),
|
||||
cond({ condition: "template", value_template: "{{ true }}" }),
|
||||
]);
|
||||
|
||||
// subtree 0: grouped top-level server siblings (state.a AND template)
|
||||
// subtree 1: the server child of the mixed OR (state.b)
|
||||
expect(split.serverSubtrees).toHaveLength(2);
|
||||
expect(split.serverSubtrees[0].coreCondition).toEqual({
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ condition: "state", entity_id: "light.a", state: "on" },
|
||||
{ condition: "template", value_template: "{{ true }}" },
|
||||
],
|
||||
});
|
||||
expect(split.serverSubtrees[1].coreCondition).toEqual({
|
||||
condition: "state",
|
||||
entity_id: "light.b",
|
||||
state: "on",
|
||||
});
|
||||
|
||||
const withScreen = (v: boolean) => clientEvaluator(new Map([[screen, v]]));
|
||||
// top group true, OR satisfied by state.b
|
||||
expect(split.evaluate(withScreen(false), { "0": true, "1": true })).toBe(
|
||||
true
|
||||
);
|
||||
// top group true, OR satisfied by screen only
|
||||
expect(split.evaluate(withScreen(true), { "0": true, "1": false })).toBe(
|
||||
true
|
||||
);
|
||||
// top group false dominates
|
||||
expect(split.evaluate(withScreen(true), { "0": false, "1": true })).toBe(
|
||||
false
|
||||
);
|
||||
// top group true but OR fully false
|
||||
expect(split.evaluate(withScreen(false), { "0": true, "1": false })).toBe(
|
||||
false
|
||||
);
|
||||
// missing a result → unknown
|
||||
expect(split.evaluate(withScreen(false), { "0": true })).toBe(undefined);
|
||||
});
|
||||
|
||||
it("treats an empty condition list as visible (vacuous AND)", () => {
|
||||
const split = splitConditionTree([]);
|
||||
expect(split.serverSubtrees).toHaveLength(0);
|
||||
expect(split.evaluate(noClient, {})).toBe(true);
|
||||
});
|
||||
|
||||
it("passes the actual client condition object to the evaluator", () => {
|
||||
const screen = cond({ condition: "screen", media_query: "x" });
|
||||
const split = splitConditionTree([screen]);
|
||||
const evaluator = vi.fn<ClientConditionEvaluator>(() => true);
|
||||
|
||||
expect(split.evaluate(evaluator, {})).toBe(true);
|
||||
expect(evaluator).toHaveBeenCalledWith(screen);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,650 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isClientCondition,
|
||||
isLogicalCondition,
|
||||
isPureClientCondition,
|
||||
isServerCondition,
|
||||
translateToCoreCondition,
|
||||
} from "../../../src/common/condition/translate";
|
||||
import type { VisibilityCondition } from "../../../src/panels/lovelace/common/validate-condition";
|
||||
|
||||
const cond = (c: any): VisibilityCondition => c as VisibilityCondition;
|
||||
|
||||
describe("isLogicalCondition", () => {
|
||||
it.each(["and", "or", "not"])("recognizes %s", (condition) => {
|
||||
expect(isLogicalCondition(cond({ condition }))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-logical and legacy conditions", () => {
|
||||
expect(isLogicalCondition(cond({ condition: "state" }))).toBe(false);
|
||||
expect(isLogicalCondition(cond({ entity: "light.a", state: "on" }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isServerCondition / isClientCondition", () => {
|
||||
it("classifies stateful lovelace leaves as server", () => {
|
||||
for (const c of [
|
||||
{ condition: "state", entity: "light.a", state: "on" },
|
||||
{ condition: "numeric_state", entity: "sensor.a", above: 5 },
|
||||
]) {
|
||||
expect(isServerCondition(cond(c))).toBe(true);
|
||||
expect(isClientCondition(cond(c))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies legacy { entity, state } conditions as server", () => {
|
||||
expect(isServerCondition(cond({ entity: "light.a", state: "on" }))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies newly-available core leaves as server", () => {
|
||||
for (const c of [
|
||||
{ condition: "template", value_template: "{{ true }}" },
|
||||
{ condition: "sun", after: "sunset" },
|
||||
{ condition: "zone", entity_id: "person.a", zone: "zone.home" },
|
||||
{ condition: "device", device_id: "abc", domain: "light" },
|
||||
// integration-provided condition
|
||||
{ condition: "my_integration.is_active" },
|
||||
]) {
|
||||
expect(isServerCondition(cond(c))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies client-only leaves as client", () => {
|
||||
for (const c of [
|
||||
{ condition: "screen", media_query: "(min-width: 600px)" },
|
||||
{ condition: "user", users: ["u1"] },
|
||||
{ condition: "view_columns", min: 2 },
|
||||
{ condition: "location", locations: ["home"] },
|
||||
{ condition: "time", after: "08:00" },
|
||||
]) {
|
||||
expect(isClientCondition(cond(c))).toBe(true);
|
||||
expect(isServerCondition(cond(c))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("treats a compound as server only when every descendant is server", () => {
|
||||
const allServer = cond({
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ condition: "state", entity: "light.a", state: "on" },
|
||||
{ condition: "template", value_template: "{{ true }}" },
|
||||
],
|
||||
});
|
||||
expect(isServerCondition(allServer)).toBe(true);
|
||||
|
||||
const mixed = cond({
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ condition: "state", entity: "light.a", state: "on" },
|
||||
{ condition: "screen", media_query: "(min-width: 600px)" },
|
||||
],
|
||||
});
|
||||
expect(isServerCondition(mixed)).toBe(false);
|
||||
expect(isClientCondition(mixed)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles or / not and deep nesting", () => {
|
||||
expect(
|
||||
isServerCondition(
|
||||
cond({
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ condition: "state", entity: "light.a", state: "on" },
|
||||
{
|
||||
condition: "not",
|
||||
conditions: [
|
||||
{ condition: "numeric_state", entity: "sensor.a", above: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isServerCondition(
|
||||
cond({
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ condition: "state", entity: "light.a", state: "on" },
|
||||
{
|
||||
condition: "not",
|
||||
conditions: [{ condition: "user", users: ["u1"] }],
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("treats an empty compound as server (vacuously)", () => {
|
||||
expect(isServerCondition(cond({ condition: "and", conditions: [] }))).toBe(
|
||||
true
|
||||
);
|
||||
expect(isServerCondition(cond({ condition: "and" }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPureClientCondition", () => {
|
||||
it("is true for a client-only leaf, false for a server leaf", () => {
|
||||
expect(
|
||||
isPureClientCondition(cond({ condition: "user", users: ["u1"] }))
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPureClientCondition(
|
||||
cond({ condition: "state", entity: "light.a", state: "on" })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("treats legacy { entity, state } conditions as not pure-client", () => {
|
||||
expect(
|
||||
isPureClientCondition(cond({ entity: "light.a", state: "on" }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is true only when every descendant is client", () => {
|
||||
const allClient = cond({
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ condition: "screen", media_query: "(min-width: 600px)" },
|
||||
{ condition: "user", users: ["u1"] },
|
||||
],
|
||||
});
|
||||
expect(isPureClientCondition(allClient)).toBe(true);
|
||||
|
||||
// A mixed tree is neither pure-server nor pure-client.
|
||||
const mixed = cond({
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ condition: "state", entity: "light.a", state: "on" },
|
||||
{ condition: "screen", media_query: "(min-width: 600px)" },
|
||||
],
|
||||
});
|
||||
expect(isPureClientCondition(mixed)).toBe(false);
|
||||
expect(isServerCondition(mixed)).toBe(false);
|
||||
expect(isClientCondition(mixed)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles deep nesting and empty compounds", () => {
|
||||
expect(
|
||||
isPureClientCondition(
|
||||
cond({
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ condition: "time", after: "08:00" },
|
||||
{
|
||||
condition: "not",
|
||||
conditions: [{ condition: "user", users: ["u1"] }],
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isPureClientCondition(
|
||||
cond({
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ condition: "time", after: "08:00" },
|
||||
{
|
||||
condition: "not",
|
||||
conditions: [
|
||||
{ condition: "numeric_state", entity: "sensor.a", above: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
// every() over an empty list is vacuously true.
|
||||
expect(
|
||||
isPureClientCondition(cond({ condition: "and", conditions: [] }))
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("translateToCoreCondition", () => {
|
||||
describe("state", () => {
|
||||
it("renames entity → entity_id", () => {
|
||||
expect(
|
||||
translateToCoreCondition(
|
||||
cond({ condition: "state", entity: "light.a", state: "on" })
|
||||
)
|
||||
).toEqual({ condition: "state", entity_id: "light.a", state: "on" });
|
||||
});
|
||||
|
||||
it("keeps attribute and array state values", () => {
|
||||
expect(
|
||||
translateToCoreCondition(
|
||||
cond({
|
||||
condition: "state",
|
||||
entity: "climate.a",
|
||||
attribute: "preset_mode",
|
||||
state: ["home", "away"],
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
condition: "state",
|
||||
entity_id: "climate.a",
|
||||
attribute: "preset_mode",
|
||||
state: ["home", "away"],
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps state_not in a not", () => {
|
||||
expect(
|
||||
translateToCoreCondition(
|
||||
cond({ condition: "state", entity: "light.a", state_not: "on" })
|
||||
)
|
||||
).toEqual({
|
||||
condition: "not",
|
||||
conditions: [{ condition: "state", entity_id: "light.a", state: "on" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps an array state_not in a single not", () => {
|
||||
expect(
|
||||
translateToCoreCondition(
|
||||
cond({
|
||||
condition: "state",
|
||||
entity: "light.a",
|
||||
state_not: ["on", "unavailable"],
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
condition: "not",
|
||||
conditions: [
|
||||
{
|
||||
condition: "state",
|
||||
entity_id: "light.a",
|
||||
state: ["on", "unavailable"],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers state over state_not when both are present", () => {
|
||||
expect(
|
||||
translateToCoreCondition(
|
||||
cond({
|
||||
condition: "state",
|
||||
entity: "light.a",
|
||||
state: "on",
|
||||
state_not: "off",
|
||||
})
|
||||
)
|
||||
).toEqual({ condition: "state", entity_id: "light.a", state: "on" });
|
||||
});
|
||||
|
||||
it("passes an entity-id comparison value through unchanged", () => {
|
||||
expect(
|
||||
translateToCoreCondition(
|
||||
cond({
|
||||
condition: "state",
|
||||
entity: "light.a",
|
||||
state: "input_select.b",
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
condition: "state",
|
||||
entity_id: "light.a",
|
||||
state: "input_select.b",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes an already-core state condition through", () => {
|
||||
const core = {
|
||||
condition: "state",
|
||||
entity_id: "light.a",
|
||||
state: "on",
|
||||
for: { minutes: 5 },
|
||||
};
|
||||
expect(translateToCoreCondition(cond(core))).toEqual(core);
|
||||
});
|
||||
});
|
||||
|
||||
describe("numeric_state", () => {
|
||||
it("renames entity → entity_id and keeps above/below/attribute", () => {
|
||||
expect(
|
||||
translateToCoreCondition(
|
||||
cond({
|
||||
condition: "numeric_state",
|
||||
entity: "sensor.a",
|
||||
attribute: "battery",
|
||||
above: 10,
|
||||
below: 90,
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
condition: "numeric_state",
|
||||
entity_id: "sensor.a",
|
||||
attribute: "battery",
|
||||
above: 10,
|
||||
below: 90,
|
||||
});
|
||||
});
|
||||
|
||||
it("coerces non-entity-id string bounds to numbers (core treats strings as entities)", () => {
|
||||
expect(
|
||||
translateToCoreCondition(
|
||||
cond({
|
||||
condition: "numeric_state",
|
||||
entity: "sensor.a",
|
||||
above: "5",
|
||||
below: "10.5",
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
condition: "numeric_state",
|
||||
entity_id: "sensor.a",
|
||||
above: 5,
|
||||
below: 10.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes an entity-id reference bound through for core to resolve", () => {
|
||||
expect(
|
||||
translateToCoreCondition(
|
||||
cond({
|
||||
condition: "numeric_state",
|
||||
entity: "sensor.a",
|
||||
above: "input_number.threshold",
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
condition: "numeric_state",
|
||||
entity_id: "sensor.a",
|
||||
above: "input_number.threshold",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops stray non-core fields rather than forwarding them", () => {
|
||||
expect(
|
||||
translateToCoreCondition(
|
||||
cond({
|
||||
condition: "numeric_state",
|
||||
entity: "sensor.a",
|
||||
above: 5,
|
||||
bogus: "x",
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
condition: "numeric_state",
|
||||
entity_id: "sensor.a",
|
||||
above: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a non-numeric, non-entity-id bound (lovelace ignores it)", () => {
|
||||
expect(
|
||||
translateToCoreCondition(
|
||||
cond({
|
||||
condition: "numeric_state",
|
||||
entity: "sensor.a",
|
||||
above: "foo",
|
||||
below: 10,
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
condition: "numeric_state",
|
||||
entity_id: "sensor.a",
|
||||
below: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("coerces an empty-string bound to 0 (matching lovelace Number())", () => {
|
||||
expect(
|
||||
translateToCoreCondition(
|
||||
cond({ condition: "numeric_state", entity: "sensor.a", above: "" })
|
||||
)
|
||||
).toEqual({
|
||||
condition: "numeric_state",
|
||||
entity_id: "sensor.a",
|
||||
above: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a non-finite numeric bound rather than emitting Infinity", () => {
|
||||
expect(
|
||||
translateToCoreCondition(
|
||||
cond({
|
||||
condition: "numeric_state",
|
||||
entity: "sensor.a",
|
||||
above: "1e400",
|
||||
})
|
||||
)
|
||||
).toEqual({ condition: "numeric_state", entity_id: "sensor.a" });
|
||||
});
|
||||
|
||||
it("passes an already-core numeric_state condition through", () => {
|
||||
const core = {
|
||||
condition: "numeric_state",
|
||||
entity_id: "sensor.a",
|
||||
above: "input_number.b",
|
||||
};
|
||||
expect(translateToCoreCondition(cond(core))).toEqual(core);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy conditions", () => {
|
||||
it("treats { entity, state } as a state condition", () => {
|
||||
expect(
|
||||
translateToCoreCondition(cond({ entity: "light.a", state: "on" }))
|
||||
).toEqual({ condition: "state", entity_id: "light.a", state: "on" });
|
||||
});
|
||||
|
||||
it("wraps legacy state_not in a not", () => {
|
||||
expect(
|
||||
translateToCoreCondition(cond({ entity: "light.a", state_not: "on" }))
|
||||
).toEqual({
|
||||
condition: "not",
|
||||
conditions: [{ condition: "state", entity_id: "light.a", state: "on" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("passthrough types", () => {
|
||||
it.each([
|
||||
{ condition: "template", value_template: "{{ is_state('a','on') }}" },
|
||||
{ condition: "sun", after: "sunset", after_offset: -3600 },
|
||||
{ condition: "zone", entity_id: "person.a", zone: "zone.home" },
|
||||
{ condition: "device", device_id: "abc", domain: "light", type: "is_on" },
|
||||
{ condition: "my_integration.is_active", target: { entity_id: "x.y" } },
|
||||
])("passes $condition through unchanged", (c) => {
|
||||
expect(translateToCoreCondition(cond(c))).toEqual(c);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logical combinators", () => {
|
||||
it("translates and children recursively", () => {
|
||||
expect(
|
||||
translateToCoreCondition(
|
||||
cond({
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ condition: "state", entity: "light.a", state: "on" },
|
||||
{ condition: "template", value_template: "{{ true }}" },
|
||||
],
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ condition: "state", entity_id: "light.a", state: "on" },
|
||||
{ condition: "template", value_template: "{{ true }}" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("translates or children recursively", () => {
|
||||
expect(
|
||||
translateToCoreCondition(
|
||||
cond({
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ condition: "numeric_state", entity: "sensor.a", above: 5 },
|
||||
],
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ condition: "numeric_state", entity_id: "sensor.a", above: 5 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a single-child not as a plain not", () => {
|
||||
expect(
|
||||
translateToCoreCondition(
|
||||
cond({
|
||||
condition: "not",
|
||||
conditions: [
|
||||
{ condition: "state", entity: "light.a", state: "on" },
|
||||
],
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
condition: "not",
|
||||
conditions: [{ condition: "state", entity_id: "light.a", state: "on" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves ¬(AND) semantics for a multi-child not", () => {
|
||||
// Lovelace `not` is ¬(AND children); core `not` is ¬(OR children).
|
||||
// Wrapping in an `and` keeps the original meaning.
|
||||
expect(
|
||||
translateToCoreCondition(
|
||||
cond({
|
||||
condition: "not",
|
||||
conditions: [
|
||||
{ condition: "state", entity: "light.a", state: "on" },
|
||||
{ condition: "state", entity: "light.b", state: "on" },
|
||||
],
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
condition: "not",
|
||||
conditions: [
|
||||
{
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ condition: "state", entity_id: "light.a", state: "on" },
|
||||
{ condition: "state", entity_id: "light.b", state: "on" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("handles a compound mixing lovelace and already-core children", () => {
|
||||
expect(
|
||||
translateToCoreCondition(
|
||||
cond({
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ condition: "state", entity: "light.a", state: "on" },
|
||||
{ condition: "state", entity_id: "light.b", state: "off" },
|
||||
{
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ condition: "numeric_state", entity: "sensor.a", below: 3 },
|
||||
{ condition: "template", value_template: "{{ false }}" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ condition: "state", entity_id: "light.a", state: "on" },
|
||||
{ condition: "state", entity_id: "light.b", state: "off" },
|
||||
{
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ condition: "numeric_state", entity_id: "sensor.a", below: 3 },
|
||||
{ condition: "template", value_template: "{{ false }}" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("translates an empty not to false (¬AND of nothing), not core's true", () => {
|
||||
// checkConditionsMet: not([]) = !every([]) = !true = false.
|
||||
// A naive { condition: "not", conditions: [] } would be true in core.
|
||||
expect(
|
||||
translateToCoreCondition(cond({ condition: "not", conditions: [] }))
|
||||
).toEqual({
|
||||
condition: "not",
|
||||
conditions: [{ condition: "and", conditions: [] }],
|
||||
});
|
||||
});
|
||||
|
||||
it("translates empty and/or directly (already agree with core)", () => {
|
||||
expect(
|
||||
translateToCoreCondition(cond({ condition: "and", conditions: [] }))
|
||||
).toEqual({ condition: "and", conditions: [] });
|
||||
expect(
|
||||
translateToCoreCondition(cond({ condition: "or", conditions: [] }))
|
||||
).toEqual({ condition: "or", conditions: [] });
|
||||
});
|
||||
|
||||
it("treats a logical condition with no conditions key as vacuously true", () => {
|
||||
for (const condition of ["and", "or", "not"]) {
|
||||
expect(translateToCoreCondition(cond({ condition }))).toEqual({
|
||||
condition: "and",
|
||||
conditions: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("incomplete conditions resolve to always-false", () => {
|
||||
// ¬(AND of nothing) = ¬true = false; matches checkConditionsMet, which
|
||||
// short-circuits incomplete state conditions to false, and avoids emitting
|
||||
// a schema-invalid core condition.
|
||||
const ALWAYS_FALSE = {
|
||||
condition: "not",
|
||||
conditions: [{ condition: "and", conditions: [] }],
|
||||
};
|
||||
|
||||
it.each([
|
||||
[
|
||||
"state with an entity but no value",
|
||||
{ condition: "state", entity: "light.a" },
|
||||
],
|
||||
["state with a value but no entity", { condition: "state", state: "on" }],
|
||||
["legacy entity with no state", { entity: "light.a" }],
|
||||
["empty object", {}],
|
||||
])("resolves %s to always-false", (_label, input) => {
|
||||
expect(translateToCoreCondition(cond(input))).toEqual(ALWAYS_FALSE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("known limitations (documented, deferred)", () => {
|
||||
it("passes a non-input_* entity-id comparison value through unchanged", () => {
|
||||
// KNOWN LIMITATION: lovelace resolves any entity-id value to its live
|
||||
// state; core's `state` condition only dereferences `input_*` entities.
|
||||
// We pin the current passthrough behavior; a faithful fix would emit a
|
||||
// `template` condition (see translate.ts).
|
||||
expect(
|
||||
translateToCoreCondition(
|
||||
cond({ condition: "state", entity: "light.a", state: "sensor.b" })
|
||||
)
|
||||
).toEqual({
|
||||
condition: "state",
|
||||
entity_id: "light.a",
|
||||
state: "sensor.b",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
import type { ReactiveControllerHost } from "@lit/reactive-element/reactive-controller";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConditionEvaluation } from "../../../src/common/controllers/condition-evaluator-controller";
|
||||
import { ConditionEvaluatorController } from "../../../src/common/controllers/condition-evaluator-controller";
|
||||
import type { VisibilityCondition } from "../../../src/panels/lovelace/common/validate-condition";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
|
||||
const cond = (c: any): VisibilityCondition => c as VisibilityCondition;
|
||||
|
||||
const tick = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 10);
|
||||
});
|
||||
|
||||
interface CapturedSubscription {
|
||||
condition: any;
|
||||
push: (message: {
|
||||
result?: boolean;
|
||||
error?: string | { code: string; message: string };
|
||||
}) => void;
|
||||
unsub: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
let subs: CapturedSubscription[];
|
||||
|
||||
const createHost = (): ReactiveControllerHost => ({
|
||||
addController: vi.fn(),
|
||||
removeController: vi.fn(),
|
||||
requestUpdate: vi.fn(),
|
||||
updateComplete: Promise.resolve(true),
|
||||
});
|
||||
|
||||
const createHass = (overrides: Partial<HomeAssistant> = {}): HomeAssistant =>
|
||||
({
|
||||
connection: {
|
||||
subscribeMessage: vi.fn((onChange: any, msg: any) => {
|
||||
const unsub = vi.fn();
|
||||
subs.push({ condition: msg.condition, push: onChange, unsub });
|
||||
return Promise.resolve(unsub);
|
||||
}),
|
||||
},
|
||||
states: {},
|
||||
user: { id: "user1" },
|
||||
locale: { time_zone: "local" },
|
||||
config: { time_zone: "America/New_York" },
|
||||
...overrides,
|
||||
}) as unknown as HomeAssistant;
|
||||
|
||||
interface Harness {
|
||||
controller: ConditionEvaluatorController;
|
||||
host: ReactiveControllerHost;
|
||||
results: { result: ConditionEvaluation; error?: string }[];
|
||||
last: () => { result: ConditionEvaluation; error?: string } | undefined;
|
||||
}
|
||||
|
||||
const setup = async (
|
||||
conditions: VisibilityCondition[],
|
||||
hass: HomeAssistant = createHass()
|
||||
): Promise<Harness> => {
|
||||
const host = createHost();
|
||||
const results: { result: ConditionEvaluation; error?: string }[] = [];
|
||||
const controller = new ConditionEvaluatorController(host, {
|
||||
resubscribeDelay: 0,
|
||||
onResult: (result, error) => results.push({ result, error }),
|
||||
});
|
||||
controller.hostConnected();
|
||||
controller.observe(conditions, hass);
|
||||
await tick();
|
||||
return { controller, host, results, last: () => results[results.length - 1] };
|
||||
};
|
||||
|
||||
describe("ConditionEvaluatorController", () => {
|
||||
beforeEach(() => {
|
||||
subs = [];
|
||||
global.matchMedia = vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("evaluates an all-client tree without opening subscriptions", async () => {
|
||||
const { controller, last } = await setup([
|
||||
cond({ condition: "user", users: ["user1"] }),
|
||||
]);
|
||||
expect(subs).toHaveLength(0);
|
||||
expect(controller.result).toBe("visible");
|
||||
expect(last()?.result).toBe("visible");
|
||||
});
|
||||
|
||||
it("hides when an all-client condition is not met", async () => {
|
||||
const { controller } = await setup([
|
||||
cond({ condition: "user", users: ["other"] }),
|
||||
]);
|
||||
expect(controller.result).toBe("hidden");
|
||||
});
|
||||
|
||||
it("opens one subscription per server leaf with the translated core condition", async () => {
|
||||
const { controller } = await setup([
|
||||
cond({ condition: "state", entity: "light.a", state: "on" }),
|
||||
]);
|
||||
expect(subs).toHaveLength(1);
|
||||
expect(subs[0].condition).toEqual({
|
||||
condition: "state",
|
||||
entity_id: "light.a",
|
||||
state: "on",
|
||||
});
|
||||
// unknown until the first push
|
||||
expect(controller.result).toBe("unknown");
|
||||
});
|
||||
|
||||
it("updates the result from server pushes", async () => {
|
||||
const { controller } = await setup([
|
||||
cond({ condition: "state", entity: "light.a", state: "on" }),
|
||||
]);
|
||||
subs[0].push({ result: true });
|
||||
expect(controller.result).toBe("visible");
|
||||
subs[0].push({ result: false });
|
||||
expect(controller.result).toBe("hidden");
|
||||
});
|
||||
|
||||
it("groups sibling server conditions into a single subscription", async () => {
|
||||
await setup([
|
||||
cond({ condition: "state", entity: "light.a", state: "on" }),
|
||||
cond({ condition: "template", value_template: "{{ true }}" }),
|
||||
]);
|
||||
expect(subs).toHaveLength(1);
|
||||
expect(subs[0].condition).toEqual({
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ condition: "state", entity_id: "light.a", state: "on" },
|
||||
{ condition: "template", value_template: "{{ true }}" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("combines a server subtree with a client leaf", async () => {
|
||||
// user1 matches → client leaf true; result follows the server push
|
||||
const { controller } = await setup([
|
||||
cond({ condition: "state", entity: "light.a", state: "on" }),
|
||||
cond({ condition: "user", users: ["user1"] }),
|
||||
]);
|
||||
expect(subs).toHaveLength(1);
|
||||
subs[0].push({ result: true });
|
||||
expect(controller.result).toBe("visible");
|
||||
subs[0].push({ result: false });
|
||||
expect(controller.result).toBe("hidden");
|
||||
});
|
||||
|
||||
it("hides and surfaces the message on a subscription error", async () => {
|
||||
const { controller, last } = await setup([
|
||||
cond({ condition: "state", entity: "light.a", state: "on" }),
|
||||
]);
|
||||
subs[0].push({ error: "Invalid condition" });
|
||||
expect(controller.result).toBe("hidden");
|
||||
expect(controller.error).toBe("Invalid condition");
|
||||
expect(last()?.error).toBe("Invalid condition");
|
||||
});
|
||||
|
||||
it("clears the error once the subscription recovers", async () => {
|
||||
const { controller } = await setup([
|
||||
cond({ condition: "state", entity: "light.a", state: "on" }),
|
||||
]);
|
||||
subs[0].push({ error: "boom" });
|
||||
expect(controller.error).toBe("boom");
|
||||
subs[0].push({ result: true });
|
||||
expect(controller.result).toBe("visible");
|
||||
expect(controller.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("re-subscribes when the condition tree changes and tears the old one down", async () => {
|
||||
const hass = createHass();
|
||||
const host = createHost();
|
||||
const controller = new ConditionEvaluatorController(host, {
|
||||
resubscribeDelay: 0,
|
||||
onResult: () => undefined,
|
||||
});
|
||||
controller.hostConnected();
|
||||
|
||||
controller.observe(
|
||||
[cond({ condition: "state", entity: "light.a", state: "on" })],
|
||||
hass
|
||||
);
|
||||
await tick();
|
||||
expect(subs).toHaveLength(1);
|
||||
const firstUnsub = subs[0].unsub;
|
||||
|
||||
controller.observe(
|
||||
[cond({ condition: "state", entity: "light.b", state: "off" })],
|
||||
hass
|
||||
);
|
||||
await tick();
|
||||
|
||||
expect(firstUnsub).toHaveBeenCalledTimes(1);
|
||||
expect(subs).toHaveLength(2);
|
||||
expect(subs[1].condition).toEqual({
|
||||
condition: "state",
|
||||
entity_id: "light.b",
|
||||
state: "off",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not re-subscribe when only hass changes", async () => {
|
||||
const conditions = [
|
||||
cond({ condition: "state", entity: "light.a", state: "on" }),
|
||||
];
|
||||
const { controller } = await setup(conditions);
|
||||
expect(subs).toHaveLength(1);
|
||||
|
||||
// same conditions reference, new hass → recompute only, no new subscription
|
||||
controller.observe(conditions, createHass());
|
||||
await tick();
|
||||
expect(subs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("tears down subscriptions on host disconnect", async () => {
|
||||
const { controller } = await setup([
|
||||
cond({ condition: "state", entity: "light.a", state: "on" }),
|
||||
]);
|
||||
const { unsub } = subs[0];
|
||||
controller.hostDisconnected();
|
||||
await tick();
|
||||
expect(unsub).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores pushes that arrive after teardown", async () => {
|
||||
const { controller } = await setup([
|
||||
cond({ condition: "state", entity: "light.a", state: "on" }),
|
||||
]);
|
||||
const stale = subs[0];
|
||||
controller.hostDisconnected();
|
||||
await tick();
|
||||
stale.push({ result: true });
|
||||
// still unknown — the stale push from the torn-down subscription is ignored
|
||||
expect(controller.result).toBe("unknown");
|
||||
});
|
||||
|
||||
it("does not re-subscribe when a fresh array of equal content is passed", async () => {
|
||||
// A host re-deriving `config.visibility ?? []` each render passes a NEW
|
||||
// array reference with identical content; that must not churn subscriptions.
|
||||
const make = () => [
|
||||
cond({ condition: "state", entity: "light.a", state: "on" }),
|
||||
];
|
||||
const { controller } = await setup(make());
|
||||
expect(subs).toHaveLength(1);
|
||||
const firstUnsub = subs[0].unsub;
|
||||
|
||||
controller.observe(make(), createHass());
|
||||
await tick();
|
||||
|
||||
expect(subs).toHaveLength(1);
|
||||
expect(firstUnsub).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports unknown after disconnect rather than a stale result", async () => {
|
||||
const { controller, last } = await setup([
|
||||
cond({ condition: "state", entity: "light.a", state: "on" }),
|
||||
]);
|
||||
subs[0].push({ result: true });
|
||||
expect(controller.result).toBe("visible");
|
||||
|
||||
controller.hostDisconnected();
|
||||
await tick();
|
||||
|
||||
expect(controller.result).toBe("unknown");
|
||||
expect(last()?.result).toBe("unknown");
|
||||
});
|
||||
|
||||
it("notifies onResult only when the result actually changes", async () => {
|
||||
const { results } = await setup([
|
||||
cond({ condition: "state", entity: "light.a", state: "on" }),
|
||||
]);
|
||||
const countBefore = results.length;
|
||||
subs[0].push({ result: true });
|
||||
subs[0].push({ result: true });
|
||||
subs[0].push({ result: true });
|
||||
// one change (unknown → visible), the repeats are coalesced
|
||||
expect(results.length).toBe(countBefore + 1);
|
||||
expect(results[results.length - 1].result).toBe("visible");
|
||||
});
|
||||
|
||||
it("requests a host update when the result changes", async () => {
|
||||
const { host } = await setup([
|
||||
cond({ condition: "state", entity: "light.a", state: "on" }),
|
||||
]);
|
||||
(host.requestUpdate as ReturnType<typeof vi.fn>).mockClear();
|
||||
subs[0].push({ result: true });
|
||||
expect(host.requestUpdate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,27 @@ describe("validateConditionalConfig", () => {
|
||||
expect(validateConditionalConfig(conditions)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("server-evaluated condition validation", () => {
|
||||
it("should accept server-evaluated conditions, leaving them to core", () => {
|
||||
const conditions = [
|
||||
{ condition: "template", value_template: "{{ true }}" },
|
||||
{ condition: "sun", after: "sunset" },
|
||||
{
|
||||
condition: "zone",
|
||||
entity_id: "device_tracker.me",
|
||||
zone: "zone.home",
|
||||
},
|
||||
{
|
||||
condition: "device",
|
||||
device_id: "abc",
|
||||
domain: "light",
|
||||
type: "is_on",
|
||||
},
|
||||
] as any;
|
||||
expect(validateConditionalConfig(conditions)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkConditionsMet", () => {
|
||||
|
||||
Reference in New Issue
Block a user