Compare commits

...

18 Commits

Author SHA1 Message Date
Petar Petrov 19231b9e78 Demote the unused state/numeric_state condition editors to base classes
With the automation condition editors handling state / numeric_state in
the dashboard editor, the lovelace `ha-card-condition-state` and
`ha-card-condition-numeric_state` elements are no longer rendered. Their
only remaining role is as the base class for the entity-filter
`-no_entity` variants, so drop the now-dead custom-element registrations
(and the redundant side-effect imports) while keeping the shared logic.
2026-06-30 09:48:31 +03:00
Petar Petrov e7daf09a1a Fold the card entity into entity-less conditions on read
A card with a host entity can carry entity-less state / numeric_state
visibility conditions that implicitly target that entity (folded in at
runtime). The editor translated them with an empty entity_id, so the
reused automation editor showed an empty, invalid entity field.

Fold the current-mode context entity into the displayed condition, and
recompute it when the entity context arrives (it can follow the
condition). Opening still does not rewrite the stored config; only
editing converts it to explicit core format.
2026-06-30 09:33:41 +03:00
Petar Petrov fff1568898 Load config translations for the reused condition editors
The dashboard visibility editor reuses the automation condition editors
(state, numeric_state, template, sun, zone, device), which label their
form fields from the `config` translation fragment. The lovelace panel
never loads that fragment, so those labels rendered blank — e.g. the
numeric_state limit-type selectors and the above/below fields.

Load the `config` fragment when the conditions editor first renders, so
the embedded editors resolve their labels.
2026-06-30 09:22:54 +03:00
Petar Petrov db8bd28b07 Keep conditional cards mounted while hidden for server conditions
A conditional card gated on a server-evaluated condition (template, sun,
zone, device) never reappeared: hui-card removes a hidden child card from
the DOM, tearing down the evaluator, and the synchronous seed can revive a
client condition but not a server one, so the subscription was never
(re)opened and the server result that would show the card never arrived.

Set connectedWhileHidden so the conditional card/row stay mounted while
hidden and keep their subscriptions alive, like the other cards that must
keep working while hidden. The inner element is still unmounted when
hidden, so there is no extra render cost.
2026-06-29 18:04:44 +03:00
Petar Petrov e773ba4ded Evaluate conditional picture element visibility server-side
The picture-elements `conditional` element was the one visibility
consumer still evaluating fully client-side, so its stateful conditions
were not delegated to core (and server-class types it could not evaluate
fell through to a permanently-hidden result). Convert it to a
ReactiveElement driven by ConditionalListenerMixin, exactly like its
sibling hui-conditional-base, so the evaluator delegates stateful
conditions through subscribe_condition and evaluates the client-only
ones locally.
2026-06-29 17:36:14 +03:00
Petar Petrov a9a2d17741 Accept server-evaluated conditions in validateConditionalConfig
The conditional card/row/element gate their config on
validateConditionalConfig, which only knew the client-side condition
types and rejected the server-evaluated ones (template, sun, zone,
device, and integration-provided conditions). Now that those are
authorable and delegated to core, accept them — core validates them —
so configuring one no longer throws "Invalid configuration".
2026-06-29 17:36:05 +03:00
Petar Petrov 49a7814115 Remove the orphaned conditional-listener no-op methods
`addConditionalListener` / `clearConditionalListeners` became no-op
shims once the evaluator took over subscriptions and teardown; their only
callers were the listener-wiring removed in the migration. Drop both
methods, the mixin's now-redundant `disconnectedCallback` override, and
the stale `clearConditionalListeners()` call in hui-conditional-base.
2026-06-29 17:24:48 +03:00
Petar Petrov 002bf491bf Pin the condition editor live-test for hidden and invalid configs
The per-row live-test set `invalid` (or hidden) directly but left its
evaluator callback unguarded, so the evaluator's torn-down `unknown`
result — fired ~500ms after observing `undefined` — clobbered the pinned
state until the next hass tick re-pinned it, causing a transient flicker.

Add the same `_override` guard the sibling visibility-status banner
already uses: the hidden / client-invalid branches set the result
directly and pin it, and the evaluator callback ignores results while
pinned.
2026-06-29 16:57:38 +03:00
Petar Petrov 43fcd1b0a4 Remove the superseded client-only condition listeners
The dashboard now evaluates visibility through the reactive condition
evaluator (which uses `observeConditionChanges`), so the old synchronous
client-only listener path has no remaining callers.

- Delete `ConditionListenersController` and the `setupConditionListeners` /
  `setupMediaQueryListeners` / `setupTimeListeners` helpers.
- Keep `observeConditionChanges` and the shared time-boundary scheduler, and
  re-point their tests onto it so the scheduling edge cases stay covered.
2026-06-29 16:19:07 +03:00
Petar Petrov ab031ab139 Edit dashboard state conditions via the core condition editors
Route `state` / `numeric_state` visibility conditions to the core automation
condition editors (outside entity-filter mode), so they share one editing
surface with the server-class types and are authored in core format.

- Read both: existing lovelace-format conditions (`entity`, `state_not`, …)
  are translated to core for display; a `state_not` shows as `not(state)`.
- Write new with touch-to-convert: opening a condition leaves it untouched;
  editing or adding one persists it in core format (`entity_id`, `state` list).
- Entity-filter mode keeps the lovelace no-entity syntax and editors.
- Register the core `state` / `numeric_state` editors (dynamicElement only
  renders a tag, it does not define the element) and widen the editor chain's
  condition arrays to the mixed visibility union.
2026-06-29 16:18:51 +03:00
Petar Petrov 07030e6575 Evaluate the visibility status banner server-side
Drive the card-level visibility summary banner with the same
ConditionEvaluatorController used by the per-condition live-test, so a set
containing server-class conditions reports its real visible/hidden verdict
instead of being flagged as an invalid configuration.

- Add a distinct "unknown" banner state for while a server result is still
  pending, separate from "invalid" (a genuine configuration error).
- Keep a client-side validity check for purely client trees, and fold the
  card entity into the observed conditions, matching the per-condition editor.
- Extract isPureClientCondition (every leaf client-side, as opposed to
  isClientCondition's any-leaf) so both consumers share one classifier.
2026-06-29 15:37:15 +03:00
Petar Petrov c32ae22f63 Evaluate the visibility condition editor live-test through the reactive evaluator
Drive the per-condition live-test indicator with the same
ConditionEvaluatorController the dashboard uses at runtime, so server-class
conditions (template/sun/zone/device and core-format state/numeric_state) get
a real subscribe_condition-backed verdict instead of a neutral indicator.
Client-only conditions stay evaluated locally and mixed logical trees combine
both via three-valued logic.

- Fold the card entity into the observed condition exactly as the runtime
  mixin does, and memoize the folded array so the evaluator's signature memo
  keeps hitting on hass-only updates.
- Map the evaluator verdict to the indicator: visible -> pass, hidden -> fail,
  pending -> unknown, server error -> invalid (raw error shown as the tooltip
  detail, localized label kept as the aria-label).
- Keep a client-side validity check for purely client trees so a malformed
  client-only config still surfaces as invalid.
- Recurse the no-entity (filter-mode) suppression so nested entity-less
  conditions are handled, and report an as-yet-unknown manual test as no
  result rather than a failure.
- Drop the now-unused invalid-config alert and its orphaned translation keys.
2026-06-29 15:25:20 +03:00
Petar Petrov 585db17e86 Add server condition types to the dashboard visibility editor
Let the visibility editor add and edit the core-format server condition
types (template, sun, zone, device) by embedding the automation condition
editors, which already speak core format. ha-card-condition-editor
dispatches these types to ha-automation-condition-editor; the existing
lovelace editors and the and/or/not containers are unchanged, and because
the logical editors nest ha-card-condition-editor, mixed trees dispatch
each child correctly.

- extend the add-condition menu with the new types (icons + labels)
- suppress the client-side live-test for server-class conditions (and any
  logical tree containing one); checkConditionsMet can't evaluate them, so
  the indicator stays neutral instead of showing a misleading failure

Server-backed live-test and read-both/write-new conversion of lovelace
state/numeric_state conditions are follow-ups.
2026-06-29 14:46:28 +03:00
Petar Petrov 28739f7fd3 Evaluate dashboard visibility through the reactive condition evaluator
Rework ConditionalListenerMixin to derive visibility from
ConditionEvaluatorController instead of evaluating checkConditionsMet
synchronously. Stateful conditions (state, numeric_state, template, sun,
zone, device, integration) are delegated to core via subscribe_condition;
client-only conditions (screen, user, view_columns, location, time) stay
local. The mixin re-feeds the evaluator on connect and on hass/config/
column changes, and drives _updateVisibility from its tri-state verdict.

Consumers (hui-card, hui-badge, hui-section, hui-heading-badge,
hui-view-sidebar, hui-conditional-base) now read the mixin's
_conditionsVisible(), which prefers the server-aware verdict and falls back
to an optimistic synchronous seed while a server subtree is pending — exact
for legacy lovelace conditions (no flash for existing dashboards) and hidden
for core-only conditions until the server reports.

- fold the host entity_id context into the evaluator path via
  addEntityToCondition, and read core-format entity_id in
  checkStateCondition / checkStateNumericCondition so seed and server agree
- addEntityToCondition no longer grafts a context entity onto an
  already-core condition that carries its own entity_id
- the conditional card/row now evaluates legacy {entity, state} conditions
- cache the entity-folded array so the evaluator's signature memo holds
  across hass updates, and drop the cached verdict when the tree changes by
  value so the seed is used for the new tree
2026-06-29 13:34:55 +03:00
Petar Petrov 8db3f168a5 Fix condition evaluator controller lifecycle edge cases
Follow-up to the adversarial review of the controller (#52836):

- Key re-subscription on a structural signature of the condition tree
  rather than array reference identity, so a host re-deriving the array
  each render neither starves the debounce nor churns subscriptions.
- Reset the published result to `unknown` on host disconnect so a
  detached/reconnecting host never renders a stale, no-longer-live result.
- Read hass lazily in the time-boundary listeners so timezone changes are
  picked up on the next boundary instead of being pinned at subscribe time.
2026-06-29 12:44:26 +03:00
Petar Petrov aa2c8564ed Harden condition translation for incomplete and odd numeric inputs
Follow-up to the adversarial review of the translator (#52836):

- Incomplete/garbage state conditions (no entity, no value, or an empty
  object) now translate to an always-false core condition instead of a
  schema-invalid `state`, matching checkConditionsMet and avoiding a
  broken grouped subscription.
- numeric_state bounds: coerce only finite numeric strings (incl. "" -> 0)
  to numbers, pass genuine entity-id references through, and drop junk or
  non-finite strings (matching lovelace's "NaN -> ignored" and never
  emitting a non-JSON-serializable Infinity).
2026-06-29 12:44:19 +03:00
Petar Petrov aaf5986fd7 Add reactive condition evaluator controller
Phase B of delegating dashboard visibility conditions to core (#52836).

- ConditionEvaluatorController opens one subscribe_condition per server
  subtree, evaluates client leaves locally, observes screen/time
  boundaries, and exposes a tri-state visible/hidden/unknown result plus
  error. It recomputes on push/listener/hass/context change, debounces
  re-subscription when the tree changes, and tears down on disconnect.
- Add observeConditionChanges to listeners.ts (notify-only, decoupled
  from checkConditionsMet) and widen extract.ts to the VisibilityCondition
  tree, factoring time-boundary scheduling into a shared helper.
2026-06-29 12:26:35 +03:00
Petar Petrov 8c20a1041f Add dashboard visibility condition classifier, translator and splitter
Pure-logic foundation for delegating dashboard visibility conditions to
core (#52836).

- Add a VisibilityCondition type spanning the client-only lovelace
  conditions (screen, user, view_columns, location, time) and core
  automation conditions, alongside the existing lovelace Condition.
- translate.ts: classify conditions as client- or server-evaluated and
  translate server ones to core format (entity -> entity_id, state_not
  -> not-wrap, numeric bound coercion), preserving lovelace's
  not = not(AND) semantics.
- split.ts: split a tree into maximal server subtrees (one subscription
  each, sibling-grouped) plus a three-valued client combiner.
2026-06-29 12:15:19 +03:00
27 changed files with 2978 additions and 560 deletions
+15 -6
View File
@@ -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;
}, []);
+50 -78
View File
@@ -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);
}
+176
View File
@@ -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),
};
};
+251
View File
@@ -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 = [];
}
}
+163 -73
View File
@@ -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
);
}
+1 -9
View File
@@ -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);
}
+1 -9
View File
@@ -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);
}
+12 -2
View File
@@ -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);
}
+1 -9
View File
@@ -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;
+17 -3
View File
@@ -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"
}
}
},
+82 -80
View File
@@ -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);
+305
View File
@@ -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);
});
});
+650
View File
@@ -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", () => {