Compare commits

..
114 changed files with 2244 additions and 10514 deletions
+1 -6
View File
@@ -7,12 +7,6 @@ description: Home Assistant frontend PR and review guidance, including implement
Use this skill when reviewing Home Assistant frontend changes or preparing a pull request.
## Review Preparation
1. Establish the review scope from the diff and the behavior changed by it.
2. Before identifying findings, use the [project skill catalogue](../../../AGENTS.md#project-skills) to load every matching companion's `SKILL.md`. Follow applicable companion references in those skills as well; this review skill supplies the workflow, and companions supply the domain-specific criteria.
3. Apply all loaded guidance within that scope. Report only problems introduced or worsened by the changeset, anchored to changed lines. Read surrounding code to understand affected behavior, not to generate unrelated cleanup or migration requests.
## Pull Request Body
When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body.
@@ -122,3 +116,4 @@ For user-facing changes, establish the existing design context as part of fronte
- Record the applicable UI/UX evidence for user-facing changes, whether or not further input is needed.
- Keep style-only comments secondary unless they affect maintainability or user experience.
- Prefer small, direct fixes over large refactors during review follow-up.
- Load the matching `ha-frontend-*` skill when a finding falls within its area.
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -37,7 +37,7 @@ Never run `tsc` or `yarn lint:types` with file arguments. When `tsc` receives fi
## Project Skills
Detailed guidance lives in `.agents/skills/<name>/SKILL.md`. Load every matching skill before detailed implementation or review. For reviews, load `ha-frontend-review` alongside all companions that apply to the changed code or behavior:
Detailed guidance lives in project skills under `.agents/skills/`. Load the matching skill before detailed implementation or review:
- `ha-frontend-contexts`: Lit contexts, `hass` migration, and rerender-sensitive state access.
- `ha-frontend-components`: dialogs, forms, alerts, shortcuts, tooltips, panels, and Lovelace cards.
-2
View File
@@ -81,8 +81,6 @@ async function copyMapPanel(staticDir) {
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
staticPath("map/")
);
// Controls and popups of the native MapLibre engine
copyFileDir(npmPath("maplibre-gl/dist/maplibre-gl.css"), staticPath("map/"));
}
function copyZXingWasm(staticDir) {
-2
View File
@@ -1,7 +1,6 @@
import { bluetoothFixtures } from "./bluetooth/fixtures";
import { infraredFixtures } from "./infrared/fixtures";
import { matterFixtures } from "./matter/fixtures";
import { modbusFixtures } from "./modbus/fixtures";
import { mqttFixtures } from "./mqtt/fixtures";
import { radioFrequencyFixtures } from "./radio_frequency/fixtures";
import { serialFixtures } from "./serial/fixtures";
@@ -17,7 +16,6 @@ import { zwaveJsFixtures } from "./zwave_js/fixtures";
const INTEGRATIONS: ConnectivityFixtures[] = [
bluetoothFixtures,
serialFixtures,
modbusFixtures,
mqttFixtures,
matterFixtures,
infraredFixtures,
-2
View File
@@ -1,7 +1,6 @@
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { mockBluetooth } from "./bluetooth/mock";
import { mockMatter } from "./matter/mock";
import { mockModbus } from "./modbus/mock";
import { mockMqtt } from "./mqtt/mock";
import { mockRadioFrequency } from "./radio_frequency/mock";
import { mockSerial } from "./serial/mock";
@@ -13,7 +12,6 @@ import { mockZwaveJs } from "./zwave_js/mock";
const MOCKS = [
mockBluetooth,
mockSerial,
mockModbus,
mockMatter,
mockMqtt,
mockZwaveJs,
@@ -1,47 +0,0 @@
import { manifest } from "../../manifest";
import { configEntry } from "../helpers";
import type { ConnectivityFixtures } from "../types";
export const FLEXIT_ENTRY_ID = "mock-flexit";
export const SOLAREDGE_ENTRY_ID = "mock-solaredge";
export const FRONIUS_ENTRY_ID = "mock-fronius";
/** The RS-485 adapter the two serial devices share, as the Serial panel lists it. */
export const RS485_PORT = "/dev/ttyUSB1";
export const modbusFixtures: ConnectivityFixtures = {
components: ["modbus"],
commands: ["modbus/"],
manifests: [
manifest("flexit", "Flexit", {
integration_type: "device",
iot_class: "local_polling",
}),
manifest("solaredge_modbus", "SolarEdge Modbus", {
integration_type: "device",
iot_class: "local_polling",
}),
manifest("fronius", "Fronius", {
integration_type: "device",
iot_class: "local_polling",
}),
],
configEntries: [
{
type: "device",
entry: configEntry(FLEXIT_ENTRY_ID, "flexit", "Flexit Nordic S4"),
},
{
type: "device",
entry: configEntry(
SOLAREDGE_ENTRY_ID,
"solaredge_modbus",
"SolarEdge SE7K"
),
},
{
type: "device",
entry: configEntry(FRONIUS_ENTRY_ID, "fronius", "Fronius Symo"),
},
],
};
@@ -1,32 +0,0 @@
import type { ModbusConnection } from "../../../../../src/data/modbus";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import {
FLEXIT_ENTRY_ID,
FRONIUS_ENTRY_ID,
RS485_PORT,
SOLAREDGE_ENTRY_ID,
} from "./fixtures";
const CONNECTIONS: ModbusConnection[] = [
// Two integrations on one RS-485 bus, which is what the panel exists to show
{
endpoint: ["serial", RS485_PORT],
connected: true,
units: { [FLEXIT_ENTRY_ID]: [1], [SOLAREDGE_ENTRY_ID]: [2] },
},
{
endpoint: ["tcp", "192.168.1.42", 502],
connected: true,
units: { [FRONIUS_ENTRY_ID]: [1] },
},
// A second inverter behind a gateway that is not answering
{
endpoint: ["tcp", "modbus-gateway.local", 502],
connected: false,
units: { [FRONIUS_ENTRY_ID]: [2] },
},
];
export const mockModbus = (hass: MockHomeAssistant) => {
hass.mockWS("modbus/connections/list", () => ({ connections: CONNECTIONS }));
};
+2 -26
View File
@@ -1,12 +1,5 @@
import type { SerialPortUsage } from "../../../../../src/data/usb";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
// The RS-485 port below carries the Modbus connections, so both sides name the
// same config entries.
import {
FLEXIT_ENTRY_ID,
RS485_PORT,
SOLAREDGE_ENTRY_ID,
} from "../modbus/fixtures";
const PORTS: SerialPortUsage[] = [
{
@@ -62,7 +55,7 @@ const PORTS: SerialPortUsage[] = [
discovery_flows: [],
},
{
device: RS485_PORT,
device: "/dev/ttyUSB1",
resolved_device:
"/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_A50285BI-if00-port0",
serial_number: "A50285BI",
@@ -75,24 +68,7 @@ const PORTS: SerialPortUsage[] = [
bcd_device: 1536,
matching_integrations: [],
present: true,
consumers: [
{
kind: "config_entry",
title: "Flexit Nordic S4",
active: true,
domain: "flexit",
config_entry_id: FLEXIT_ENTRY_ID,
slug: null,
},
{
kind: "config_entry",
title: "SolarEdge SE7K",
active: true,
domain: "solaredge_modbus",
config_entry_id: SOLAREDGE_ENTRY_ID,
slug: null,
},
],
consumers: [],
discovery_flows: [],
},
{
+5 -4
View File
@@ -110,6 +110,7 @@
"intl-messageformat": "11.2.14",
"js-yaml": "5.4.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
@@ -162,6 +163,7 @@
"@types/culori": "4.0.1",
"@types/html-minifier-terser": "7.0.2",
"@types/leaflet": "1.9.22",
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.5",
@@ -170,7 +172,7 @@
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@versatiles/style": "5.13.1",
"@vitest/coverage-v8": "5.0.0",
"@vitest/coverage-v8": "4.1.11",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.28.9",
@@ -198,7 +200,7 @@
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lightningcss": "1.33.0",
"lint-staged": "17.5.0",
"lint-staged": "17.4.1",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
@@ -214,9 +216,8 @@
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.69.0",
"vite": "8.2.2",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.0.0",
"vitest": "4.1.11",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
-144
View File
@@ -1,144 +0,0 @@
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 { ensureArray } from "../array/ensure-array";
import {
isClientCondition,
isDisabledCondition,
isEntityReference,
isLogicalCondition,
logicalChildren,
} from "./translate";
// Combinators: true / false / undefined (unknown)
const andOf = (values: (boolean | undefined)[]): boolean | undefined => {
let unknown = false;
for (const value of values) {
if (value === false) return false;
if (value === undefined) unknown = true;
}
return unknown ? undefined : true;
};
const orOf = (values: (boolean | undefined)[]): boolean | undefined => {
let unknown = false;
for (const value of values) {
if (value === true) return true;
if (value === undefined) unknown = true;
}
return unknown ? undefined : false;
};
/**
* True when `checkConditionsMet` matches core for this leaf, so we can
* show a result before `subscribe_condition` replies.
*/
const isLocallyEvaluableServerLeaf = (
condition: VisibilityCondition,
states: HomeAssistant["states"],
context: ConditionContext
): boolean => {
const type = "condition" in condition ? condition.condition : "state";
if (type !== "state" && type !== "numeric_state") {
return false;
}
if (!("entity_id" in condition)) {
// Empty `entity: ""` falls back to the host entity, like checkStateCondition.
const target =
("entity" in condition ? condition.entity : undefined) ||
context.entity_id;
return !!target && target in states;
}
const core = condition as {
entity_id?: unknown;
attribute?: unknown;
for?: unknown;
match?: unknown;
value_template?: unknown;
state?: unknown;
above?: unknown;
below?: unknown;
};
if (
typeof core.entity_id !== "string" ||
!(core.entity_id in states) ||
core.attribute !== undefined ||
core.for !== undefined ||
core.match !== undefined ||
core.value_template !== undefined
) {
return false;
}
if (type === "numeric_state") {
const bounds = [core.above, core.below].filter((b) => b !== undefined);
return bounds.length > 0 && bounds.every((b) => typeof b === "number");
}
return !ensureArray(core.state as string | string[] | undefined)?.some(
isEntityReference
);
};
/**
* Evaluate the parts of a visibility tree the client can know exactly.
* Unknown leaves stay unknown (`not: [template]` is not treated as visible).
* Returns `undefined` when the result still depends on core.
*/
export const evaluateConditionsLocally = (
conditions: VisibilityCondition[],
hass: HomeAssistant,
context: ConditionContext
): boolean | undefined => {
const evaluateLeaf = (condition: VisibilityCondition): boolean => {
try {
return checkConditionsMet([condition as Condition], hass, context);
} catch (_err) {
return false;
}
};
const evaluateNode = (
condition: VisibilityCondition
): boolean | undefined => {
// Template `enabled` is only known to core; the local evaluator ignores it.
if ("enabled" in condition && typeof condition.enabled !== "boolean") {
return undefined;
}
if (isLogicalCondition(condition)) {
// Missing `conditions` is treated as true, matching checkAnd/Or/NotCondition.
if (condition.conditions === undefined) {
return true;
}
const values = logicalChildren(condition)
.filter((child) => !isDisabledCondition(child))
.map(evaluateNode);
if (condition.condition === "or") {
return orOf(values);
}
const all = andOf(values);
// Lovelace `not` is NOT(AND of children).
return condition.condition === "not"
? all === undefined
? undefined
: !all
: all;
}
if (
isClientCondition(condition) ||
isLocallyEvaluableServerLeaf(condition, hass.states, context)
) {
return evaluateLeaf(condition);
}
return undefined;
};
// Top-level list is AND. Skip `enabled: false` nodes, as core does.
return andOf(
conditions.filter((c) => !isDisabledCondition(c)).map(evaluateNode)
);
};
+8 -17
View File
@@ -1,25 +1,17 @@
import type {
Condition,
TimeCondition,
VisibilityCondition,
} from "../../panels/lovelace/common/validate-condition";
import { ensureArray } from "../array/ensure-array";
/**
* Extract media queries from conditions recursively
*/
export function extractMediaQueries(
conditions: VisibilityCondition[]
): string[] {
export function extractMediaQueries(conditions: Condition[]): string[] {
return conditions.reduce<string[]>((array, c) => {
if ("conditions" in c && c.conditions) {
array.push(...extractMediaQueries(ensureArray(c.conditions)));
array.push(...extractMediaQueries(c.conditions));
}
if (
"condition" in c &&
c.condition === "screen" &&
"media_query" in c &&
c.media_query
) {
if (c.condition === "screen" && c.media_query) {
array.push(c.media_query);
}
return array;
@@ -30,15 +22,14 @@ export function extractMediaQueries(
* Extract time conditions from conditions recursively
*/
export function extractTimeConditions(
conditions: VisibilityCondition[]
conditions: Condition[]
): TimeCondition[] {
return conditions.reduce<TimeCondition[]>((array, c) => {
if ("conditions" in c && c.conditions) {
array.push(...extractTimeConditions(ensureArray(c.conditions)));
array.push(...extractTimeConditions(c.conditions));
}
if ("condition" in c && c.condition === "time") {
// Dashboard `time` is always the client lovelace shape.
array.push(c as TimeCondition);
if (c.condition === "time") {
array.push(c);
}
return array;
}, []);
+78 -47
View File
@@ -1,9 +1,10 @@
import { listenMediaQuery } from "../dom/media_query";
import type { HomeAssistant } from "../../types";
import type {
TimeCondition,
VisibilityCondition,
Condition,
ConditionContext,
} from "../../panels/lovelace/common/validate-condition";
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
import { extractMediaQueries, extractTimeConditions } from "./extract";
import { calculateNextTimeUpdate } from "./time-calculator";
@@ -15,65 +16,95 @@ import { calculateNextTimeUpdate } from "./time-calculator";
const MAX_TIMEOUT_DELAY = 2147483647;
/**
* Fire onChange at the next time-condition boundary, then reschedule.
* Delays past the setTimeout max are capped and retried without firing.
* Helper to setup media query listeners for conditional visibility
*/
function scheduleTimeBoundaryListener(
getHass: () => HomeAssistant,
timeCondition: Omit<TimeCondition, "condition">,
export function setupMediaQueryListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onBoundary: () => void
onUpdate: (conditionsMet: boolean) => void,
getContext?: () => ConditionContext
): void {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const mediaQueries = extractMediaQueries(conditions);
const scheduleUpdate = () => {
// Read hass here so a timezone change applies at the next boundary.
const delay = calculateNextTimeUpdate(getHass(), timeCondition);
if (mediaQueries.length === 0) return;
if (delay === undefined) return;
// Optimization for single media query
const hasOnlyMediaQuery =
conditions.length === 1 &&
conditions[0].condition === "screen" &&
!!conditions[0].media_query;
// Cap delay to prevent setTimeout overflow
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
timeoutId = setTimeout(() => {
if (delay <= MAX_TIMEOUT_DELAY) {
onBoundary();
mediaQueries.forEach((mediaQuery) => {
const unsub = listenMediaQuery(mediaQuery, (matches) => {
if (hasOnlyMediaQuery) {
onUpdate(matches);
} else {
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);
}
});
addListener(unsub);
});
scheduleUpdate();
}
/**
* 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.
* Helper to setup time-based listeners for conditional visibility
*/
export function observeConditionChanges(
conditions: VisibilityCondition[],
getHass: () => HomeAssistant,
export function setupTimeListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onChange: () => void
onUpdate: (conditionsMet: boolean) => void,
getContext?: () => ConditionContext
): void {
extractMediaQueries(conditions).forEach((mediaQuery) => {
addListener(listenMediaQuery(mediaQuery, () => onChange()));
});
const timeConditions = extractTimeConditions(conditions);
extractTimeConditions(conditions).forEach((timeCondition) => {
scheduleTimeBoundaryListener(getHass, timeCondition, addListener, 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();
});
}
/**
* 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);
}
-170
View File
@@ -1,170 +0,0 @@
import type { Condition as CoreCondition } from "../../data/automation";
import type { VisibilityCondition } from "../../panels/lovelace/common/validate-condition";
import {
isDisabledCondition,
isLogicalCondition,
isServerCondition,
logicalChildren,
translateToCoreCondition,
} from "./translate";
/** One `subscribe_condition` (largest server subtree we can group). */
export interface ServerSubtree {
id: string;
coreCondition: CoreCondition;
}
/** Evaluate a client-only leaf. `undefined` if it cannot be decided yet. */
export type ClientConditionEvaluator = (
condition: VisibilityCondition
) => boolean | undefined;
/** Results by subtree id. `undefined` means not reported yet. */
export type ServerConditionResults = Record<string, boolean | undefined>;
export interface SplitConditionTree {
serverSubtrees: ServerSubtree[];
/**
* Combine client and server results. Returns `undefined` while a needed
* server subtree has not reported.
*/
evaluate: (
clientEvaluator: ClientConditionEvaluator,
serverResults: ServerConditionResults
) => boolean | undefined;
}
type EvalNode = (
clientEvaluator: ClientConditionEvaluator,
serverResults: ServerConditionResults
) => boolean | undefined;
// false wins AND, true wins OR, even if a sibling is still unknown.
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);
const unknownLeaf: EvalNode = () => undefined;
/**
* Split a visibility tree into server subscriptions and a local combiner.
*
* Sibling server conditions under the same parent share one subscription.
* `enabled: false` is skipped. A template `enabled` on a client node stays
* unknown. Lovelace `not` is NOT(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);
};
// Group server siblings into one subscription; recurse into client children.
const buildSiblings = (
children: VisibilityCondition[],
groupOperator: "and" | "or"
): EvalNode[] => {
const serverChildren: VisibilityCondition[] = [];
const clientChildren: VisibilityCondition[] = [];
for (const child of children) {
// Core skips disabled nodes; don't subscribe or combine them.
if (isDisabledCondition(child)) {
continue;
}
(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;
};
const build = (condition: VisibilityCondition): EvalNode => {
// Template `enabled` on a client node can't be rendered here; stay unknown.
if ("enabled" in condition && typeof condition.enabled !== "boolean") {
return unknownLeaf;
}
if (isLogicalCondition(condition)) {
const children = logicalChildren(condition);
if (condition.condition === "or") {
return orNode(buildSiblings(children, "or"));
}
if (condition.condition === "not") {
return notNode(andNode(buildSiblings(children, "and")));
}
return andNode(buildSiblings(children, "and"));
}
// Should only happen if a server leaf slipped past grouping.
if (isServerCondition(condition)) {
return addSubtree(translateToCoreCondition(condition));
}
return clientLeaf(condition);
};
const root = andNode(buildSiblings(conditions, "and"));
return {
serverSubtrees,
evaluate: (clientEvaluator, serverResults) =>
root(clientEvaluator, serverResults),
};
};
-304
View File
@@ -1,304 +0,0 @@
import type {
Condition as CoreCondition,
NumericStateCondition as CoreNumericStateCondition,
StateCondition as CoreStateCondition,
TemplateCondition as CoreTemplateCondition,
} from "../../data/automation";
import {
CONDITION_ROW_CONFIG_KEYS,
pickRowConfig,
} from "../../data/automation";
import type {
LegacyCondition,
NumericStateCondition as LovelaceNumericStateCondition,
StateCondition as LovelaceStateCondition,
VisibilityCondition,
VisibilityLogicalCondition,
} from "../../panels/lovelace/common/validate-condition";
import { ensureArray } from "../array/ensure-array";
import { isValidEntityId } from "../entity/valid_entity_id";
/** Client-only types. 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"]);
/** `and` / `or` / `not`. */
export const isLogicalCondition = (
condition: VisibilityCondition
): condition is VisibilityLogicalCondition =>
"condition" in condition && LOGICAL_CONDITION_TYPES.has(condition.condition);
/** Children of a logical condition. Core allows a single item or a list. */
export const logicalChildren = (
condition: VisibilityLogicalCondition
): VisibilityCondition[] => ensureArray(condition.conditions) ?? [];
/** True if `value` looks like an entity id, not a numeric literal. */
export const isEntityReference = (value: unknown): value is string =>
typeof value === "string" && isNaN(Number(value)) && isValidEntityId(value);
/**
* Lovelace `state` / `numeric_state` that core would evaluate differently,
* so existing dashboards keep the client result until the user edits them:
* stringified attributes, any-entity comparison values, entity-id bounds.
*/
const hasLegacyOnlySemantics = (
condition:
LovelaceStateCondition | LovelaceNumericStateCondition | LegacyCondition
): boolean => {
if ("condition" in condition && condition.condition === "numeric_state") {
return [condition.above, condition.below].some(isEntityReference);
}
const state = condition as LovelaceStateCondition | LegacyCondition;
if ("attribute" in state && state.attribute !== undefined) {
return true;
}
return [
...(ensureArray(state.state) ?? []),
...(ensureArray(state.state_not) ?? []),
].some(isEntityReference);
};
/**
* True if this node should go to `subscribe_condition`.
* A mixed `and`/`or`/`not` is client-side so it can wrap server subtrees.
* Lovelace `state`/`numeric_state` with {@link hasLegacyOnlySemantics} stay local.
*/
export const isServerCondition = (condition: VisibilityCondition): boolean => {
if (isLogicalCondition(condition)) {
return logicalChildren(condition).every(isServerCondition);
}
// `{ entity, state }` with no `condition` key is a state condition.
if (!("condition" in condition)) {
return !hasLegacyOnlySemantics(condition);
}
if (CLIENT_CONDITION_TYPES.has(condition.condition)) {
return false;
}
if (
(condition.condition === "state" ||
condition.condition === "numeric_state") &&
!("entity_id" in condition)
) {
return !hasLegacyOnlySemantics(
condition as LovelaceStateCondition | LovelaceNumericStateCondition
);
}
return true;
};
/** `enabled: false`. Core skips these inside `and` / `or` / `not`. */
export const isDisabledCondition = (condition: VisibilityCondition): boolean =>
"enabled" in condition && condition.enabled === false;
/** Inverse of {@link isServerCondition}. True if any leaf is client-side. */
export const isClientCondition = (condition: VisibilityCondition): boolean =>
!isServerCondition(condition);
/** True if every leaf is client-only (no `subscribe_condition` needed). */
export const isPureClientCondition = (
condition: VisibilityCondition
): boolean =>
isLogicalCondition(condition)
? logicalChildren(condition).every(isPureClientCondition)
: isClientCondition(condition);
/**
* Lovelace → core automation condition. Already-core types pass through.
* Client-only conditions should not be passed in.
*/
export const translateToCoreCondition = (
condition: VisibilityCondition
): CoreCondition => {
// `{ entity, state, state_not }` with no `condition` key.
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:
return condition as CoreCondition;
}
};
// Always-false: not(and of nothing). Used for incomplete configs so we don't
// emit something core's schema would reject (that would fail a grouped subscription).
const alwaysFalseCondition = (): CoreCondition => ({
condition: "not",
conditions: [{ condition: "and", conditions: [] }],
});
const translateStateCondition = (
condition: LovelaceStateCondition | CoreStateCondition | LegacyCondition
): CoreCondition => {
if ("entity_id" in condition) {
return condition as CoreStateCondition;
}
const lovelace = condition as LovelaceStateCondition;
const rowConfig = pickRowConfig(lovelace, CONDITION_ROW_CONFIG_KEYS);
// Missing entity or comparison value: checkConditionsMet is false; core
// would reject the schema.
if (
!lovelace.entity ||
(lovelace.state === undefined && lovelace.state_not === undefined)
) {
return { ...rowConfig, ...alwaysFalseCondition() };
}
const base = {
condition: "state" as const,
entity_id: lovelace.entity,
...(lovelace.attribute !== undefined
? { attribute: lovelace.attribute }
: {}),
};
// Prefer `state` when both are set, matching checkConditionsMet.
if (lovelace.state !== undefined) {
return {
...rowConfig,
...base,
state: lovelace.state,
} as CoreStateCondition;
}
// Core has no `state_not`; wrap a positive `state` in `not`.
return {
...rowConfig,
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 rowConfig = pickRowConfig(lovelace, CONDITION_ROW_CONFIG_KEYS);
// Missing entity: checkConditionsMet is false; core would reject the schema.
if (!lovelace.entity) {
return { ...rowConfig, ...alwaysFalseCondition() };
}
const above = translateNumericBound(lovelace.above, "above");
const below = translateNumericBound(lovelace.below, "below");
if (typeof above === "symbol" || typeof below === "symbol") {
// `above: +∞` / `below: -∞` can never pass.
return { ...rowConfig, ...alwaysFalseCondition() };
}
if (above === undefined && below === undefined) {
// No usable bound. Lovelace only checks that the value is numeric; core
// requires a bound, so express that as a template.
return {
...rowConfig,
...numericValueCondition(lovelace.entity, lovelace.attribute),
};
}
const core: CoreNumericStateCondition = {
...rowConfig,
condition: "numeric_state",
entity_id: lovelace.entity,
};
if (lovelace.attribute !== undefined) {
core.attribute = lovelace.attribute;
}
if (above !== undefined) {
core.above = above;
}
if (below !== undefined) {
core.below = below;
}
return core;
};
// Numeric-only check when lovelace ignored every bound.
const numericValueCondition = (
entityId: string,
attribute?: string
): CoreTemplateCondition => ({
condition: "template",
value_template:
attribute === undefined
? `{{ is_number(states(${JSON.stringify(entityId)})) }}`
: `{{ is_number(state_attr(${JSON.stringify(entityId)}, ${JSON.stringify(attribute)})) }}`,
});
// Bound that can never pass (see `translateNumericBound`).
const NEVER_SATISFIED = Symbol("never-satisfied");
/**
* Map a lovelace numeric bound onto core.
* Numeric strings become numbers (try Number() first — `"10.5"` looks like an id).
* Real entity ids pass through. Junk is dropped. ±Infinity: never-pass sides
* become {@link NEVER_SATISFIED}; always-pass sides are dropped (not JSON-safe).
*/
const translateNumericBound = (
bound: string | number | null | undefined,
side: "above" | "below"
): string | number | undefined | typeof NEVER_SATISFIED => {
// YAML `null` is the same as a missing bound.
if (bound == null) {
return undefined;
}
const numeric = typeof bound === "number" ? bound : Number(bound);
if (isNaN(numeric)) {
return typeof bound === "string" && isValidEntityId(bound)
? bound
: undefined;
}
if (!isFinite(numeric)) {
return (side === "above") === numeric > 0 ? NEVER_SATISFIED : undefined;
}
return numeric;
};
const translateLogicalCondition = (
condition: VisibilityLogicalCondition
): CoreCondition => {
const rowConfig = pickRowConfig(condition, CONDITION_ROW_CONFIG_KEYS);
// Missing `conditions` is treated as true.
if (condition.conditions === undefined) {
return { ...rowConfig, condition: "and", conditions: [] };
}
const conditions = logicalChildren(condition).map(translateToCoreCondition);
if (condition.condition === "not") {
// Lovelace `not` is NOT(AND); core `not` is NOT(OR). Keep the `and`
// wrapper even for one child: a skipped disabled child must stay false.
return {
...rowConfig,
condition: "not",
conditions: [{ condition: "and", conditions }],
};
}
return { ...rowConfig, condition: condition.condition, conditions };
};
@@ -1,353 +0,0 @@
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 { isPureClientCondition } from "../condition/translate";
import type {
ClientConditionEvaluator,
ServerConditionResults,
SplitConditionTree,
} from "../condition/split";
import { splitConditionTree } from "../condition/split";
/** `unknown` until a server subtree reports. */
export type ConditionEvaluation = "visible" | "hidden" | "unknown";
export interface ConditionEvaluatorOptions {
onResult: (result: ConditionEvaluation, error?: string) => void;
/** Wait this long before (re)opening subscriptions after the tree changes. */
resubscribeDelay?: number;
}
const DEFAULT_RESUBSCRIBE_DELAY = 50;
const firstDefined = (
values: Record<string, string | undefined>
): string | undefined => Object.values(values).find((value) => !!value);
/**
* Live evaluation of a visibility tree: `subscribe_condition` for server
* subtrees, local checks for screen/user/time/etc. Call {@link observe} when
* inputs change. Result stays `unknown` until server replies so nothing flashes.
*/
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;
// Value of the live tree vs the tree a pending resubscribe will switch to.
// Compared by content so a new array each render does not churn subscriptions.
// `undefined` is a real signature, so pending work uses `_hasPendingResubscribe`.
private _subscribedSignature?: string;
private _pendingSignature?: string;
private _hasPendingResubscribe = false;
// Resubscribe if hass.connection is replaced even when the tree is unchanged.
private _subscribedConnection?: Connection;
// Skip JSON.stringify when the same array is observed again.
private _lastConditionsRef?: VisibilityCondition[];
private _lastSignature?: string;
private _split?: SplitConditionTree;
private _serverResults: ServerConditionResults = {};
private _subtreeErrors: Record<string, string | undefined> = {};
// Template errors from core that still produced a result. Shown in editors,
// but they do not force hidden.
private _subtreeTemplateErrors: Record<string, string | undefined> = {};
private _subscriptions: Promise<UnsubscribeFunc>[] = [];
private _listeners: (() => void)[] = [];
// Ignore callbacks from a torn-down generation.
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;
}
/**
* Update inputs. Resubscribes only when the tree (or connection) changes.
*/
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();
// Subscriptions are gone; don't keep showing a stale result.
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;
// JSON turns ±Infinity into null; append them so `.inf` bound flips resubscribe.
const nonFinite: string[] = [];
const json = JSON.stringify(conditions, (_key, value) => {
if (typeof value === "number" && !isFinite(value)) {
nonFinite.push(String(value));
}
return value;
});
this._lastSignature = nonFinite.length
? `${json}|${nonFinite.join(",")}`
: json;
return this._lastSignature;
}
private _sync(): void {
if (!this._connected) {
return;
}
const signature = this._signatureOf(this._conditions);
// Resubscribe when the tree content or hass.connection changed.
const targetSignature = this._hasPendingResubscribe
? this._pendingSignature
: this._subscribedSignature;
const connectionReplaced =
this._subscribedConnection !== undefined &&
this._hass !== undefined &&
this._hass.connection !== this._subscribedConnection;
if (signature !== targetSignature || connectionReplaced) {
// Drop the old subscriptions immediately so their result can't leak through.
this._teardown();
this._hasPendingResubscribe = true;
this._pendingSignature = signature;
if (
this._conditions === undefined ||
this._conditions.every(isPureClientCondition)
) {
// All client-side; no server subscription to debounce.
this._subscribe();
return;
}
this._scheduleResubscribe();
}
// Keep client leaves live. Pending resubscribe has no split → `unknown`.
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._pendingSignature = undefined;
this._hasPendingResubscribe = false;
if (!conditions || !hass) {
// Don't mark subscribed yet; hass often arrives after connect.
this._setResult("unknown", undefined);
return;
}
this._subscribedSignature = this._signatureOf(conditions);
const split = splitConditionTree(conditions);
this._split = split;
const generation = this._generation;
const connection: Connection = hass.connection;
this._subscribedConnection = 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._subtreeTemplateErrors[subtree.id] = message.template_errors
?.length
? message.template_errors.join("\n")
: 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 {
return checkConditionsMet([condition as Condition], hass, context);
} catch (_err) {
return false;
}
};
const error = firstDefined(this._subtreeErrors);
// Don't treat a server error as `false` that a client `not` would invert.
if (error !== undefined) {
this._setResult("hidden", error);
return;
}
const value = this._split.evaluate(clientEvaluator, this._serverResults);
const result: ConditionEvaluation =
value === undefined ? "unknown" : value ? "visible" : "hidden";
this._setResult(result, firstDefined(this._subtreeTemplateErrors));
}
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 {
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._subtreeTemplateErrors = {};
this._subscribedSignature = undefined;
this._pendingSignature = undefined;
this._hasPendingResubscribe = false;
this._subscribedConnection = undefined;
}
}
@@ -0,0 +1,59 @@
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 = [];
}
}
+9 -11
View File
@@ -12,14 +12,14 @@ import {
// Generated by build-scripts/gulp/map-assets.js. The attribution comes from the
// TileJSON, deliberately: it follows whoever serves the tiles.
export const VECTOR_STYLES = {
const VECTOR_STYLES = {
light: "/static/map/light.json",
dark: "/static/map/dark.json",
} as const;
// Without it Arabic and Hebrew labels render reversed. Loaded by MapLibre's
// worker, hence a URL rather than an import.
export const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
// OSM serves no @2x variant.
@@ -33,8 +33,8 @@ const OSM_ATTRIBUTION =
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
export const CONTEXT_RESTORE_GRACE = 2000;
export const RECOVERY_THROTTLE = 30000;
const CONTEXT_RESTORE_GRACE = 2000;
const RECOVERY_THROTTLE = 30000;
// On the map, not the layer: marker clustering throws without a maximum. The
// floor is 1 because at Leaflet zoom 0 the adapter drives MapLibre to -1.
@@ -55,7 +55,7 @@ export interface MapBaseLayer {
let webGL2Supported: boolean | undefined;
// Rules out iOS below 15, older Android tablets and blocklisted drivers.
export const supportsWebGL2 = (): boolean => {
const supportsWebGL2 = (): boolean => {
if (webGL2Supported === undefined) {
try {
const context = document.createElement("canvas").getContext("webgl2");
@@ -91,7 +91,7 @@ const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
return style;
};
export const loadStyle = async (url: string): Promise<StyleSpecification> => {
const loadStyle = async (url: string): Promise<StyleSpecification> => {
const style: StyleSpecification = await (await fetch(url)).json();
if (__DEMO__) {
@@ -111,7 +111,7 @@ export const loadStyle = async (url: string): Promise<StyleSpecification> => {
// Global to MapLibre, and it throws when set twice.
let rtlTextPluginRequested = false;
export const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
if (rtlTextPluginRequested) {
return;
}
@@ -303,11 +303,9 @@ export const createBaseLayer = async (
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean,
token: string | undefined,
// Skip the vector layer, e.g. after a permanent WebGL context loss
rasterOnly = false
token: string | undefined
): Promise<MapBaseLayer> => {
if (!rasterOnly && supportsWebGL2()) {
if (supportsWebGL2()) {
let vectorLayer: MapBaseLayer | undefined;
try {
const [{ maplibreGL: createLayer }, maplibre] = await Promise.all([
-59
View File
@@ -1,59 +0,0 @@
/** Handle DOM and styles of the editable circle (MapEngine.addEditableCircle) */
/** Hit target for the radius handle; comfortably above touch minimums */
export const RESIZE_HANDLE_SIZE = 24;
/** The visible dot inside the hit target */
export const RESIZE_HANDLE_DOT_SIZE = 12;
/** Relative radius change per arrow key press on the handle */
export const RESIZE_KEY_STEP = 0.1;
/** Advertised slider maximum; a larger radius raises it (see the engine) */
export const RADIUS_ARIA_MAX = 100000;
export const createResizeHandleElement = (label?: string): HTMLElement => {
const element = document.createElement("div");
element.className = "editable-circle-resize";
element.tabIndex = 0;
element.setAttribute("role", "slider");
element.setAttribute("aria-valuemin", "1");
element.setAttribute("aria-valuemax", String(RADIUS_ARIA_MAX));
if (label) {
element.setAttribute("aria-label", label);
}
const dot = document.createElement("div");
dot.className = "editable-circle-resize-dot";
element.appendChild(dot);
return element;
};
/** Styles for the handles, included by ha-map for both engines */
export const editableCircleStyles = `
.editable-circle-center {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--primary-color);
border: 2px solid var(--card-background-color, #fff);
box-sizing: border-box;
box-shadow: var(--ha-box-shadow-s);
}
.editable-circle-resize {
width: ${RESIZE_HANDLE_SIZE}px;
height: ${RESIZE_HANDLE_SIZE}px;
display: flex;
align-items: center;
justify-content: center;
cursor: ew-resize;
}
.editable-circle-resize-dot {
width: ${RESIZE_HANDLE_DOT_SIZE}px;
height: ${RESIZE_HANDLE_DOT_SIZE}px;
border-radius: 50%;
background: var(--card-background-color, #fff);
border: 2px solid var(--primary-color);
box-sizing: border-box;
box-shadow: var(--ha-box-shadow-s);
}
`;
@@ -1,348 +0,0 @@
import type {
CircleMarker,
Control,
Map,
MarkerClusterGroup,
Polyline,
} from "leaflet";
import type { LeafletModuleType } from "../../dom/setup-leaflet-map";
import type { MapBaseLayer } from "../base-layer";
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../base-layer";
import { DecoratedMarker } from "../decorated_marker";
import { isTouch } from "../../../util/is_touch";
import type {
MapClusterOptions,
MapCircleOptions,
MapControlPosition,
MapEngine,
MapEngineOptions,
MapFitOptions,
MapItemHandle,
MapLatLng,
MapMarkerHandle,
MapMarkerOptions,
MapPath,
} from "../map-engine";
import { setMarkerAccessibility } from "../marker-accessibility";
/** A leaflet marker that knows the engine handle it was created for */
interface HandledMarker extends DecoratedMarker {
engineHandle?: LeafletMarkerHandle;
}
interface LeafletMarkerHandle extends MapMarkerHandle {
marker: HandledMarker;
}
/** The Leaflet engine: raster viewing fallback without WebGL2, no editing */
export class LeafletMapEngine implements MapEngine {
/** For the ha-map jsdom tests only */
public leafletMap?: Map;
public Leaflet?: LeafletModuleType;
private _baseLayer?: MapBaseLayer;
private _clusterable: HandledMarker[] = [];
private _cluster?: MarkerClusterGroup;
private _clusterOptions: MapClusterOptions | null = null;
private _scaleControl?: Control.Scale;
public async init(
container: HTMLElement,
options: MapEngineOptions
): Promise<void> {
const root = container.parentNode;
if (!root) {
throw new Error("Cannot set up a Leaflet map on a detached element");
}
// eslint-disable-next-line
const Leaflet = (await import("leaflet")).default as LeafletModuleType;
Leaflet.Icon.Default.imagePath = "/static/images/leaflet/images/";
await import("leaflet.markercluster");
const map = Leaflet.map(container, {
minZoom: MAP_MIN_ZOOM,
maxZoom: MAP_MAX_ZOOM,
});
map.attributionControl.setPrefix("");
for (const href of [
"/static/images/leaflet/leaflet.css",
"/static/images/leaflet/MarkerCluster.css",
]) {
const style = document.createElement("link");
style.setAttribute("href", href);
style.setAttribute("rel", "stylesheet");
root.appendChild(style);
}
map.setView(options.center, options.zoom);
// The base layer adds itself; a vector layer may still fall back to raster
this._baseLayer = await createBaseLayer(
Leaflet,
map,
options.darkMode,
options.token,
options.rasterOnly ?? false
);
this.leafletMap = map;
this.Leaflet = Leaflet;
map.zoomControl?.setPosition(options.zoomControlPosition);
const { events } = options;
if (events.click) {
map.on("click", (ev) => {
events.click!([ev.latlng.lat, ev.latlng.lng]);
});
}
if (events.zoomStart) {
map.on("zoomstart", () => events.zoomStart!());
}
if (events.moveStart) {
map.on("movestart", () => events.moveStart!());
}
}
public destroy(): void {
this.leafletMap?.remove();
this.leafletMap = undefined;
this.Leaflet = undefined;
this._baseLayer = undefined;
this._cluster = undefined;
this._clusterable = [];
this._scaleControl = undefined;
}
public invalidateSize(): void {
this.leafletMap?.invalidateSize({ debounceMoveend: true });
}
public hasUsableSize(): boolean {
if (!this.leafletMap) {
return false;
}
const size = this.leafletMap.getSize();
if (size.x > 0 && size.y > 0) {
return true;
}
const container = this.leafletMap.getContainer();
if (container.clientWidth > 0 && container.clientHeight > 0) {
// The container was laid out since Leaflet last measured it
this.leafletMap.invalidateSize(false);
return true;
}
return false;
}
public setDarkMode(darkMode: boolean): void {
this._baseLayer?.setDarkMode(darkMode);
}
public setZoomControlPosition(position: MapControlPosition): void {
this.leafletMap?.zoomControl?.setPosition(position);
}
public setScaleRuler(options: { metric: boolean } | null): void {
if (this._scaleControl) {
this.leafletMap?.removeControl(this._scaleControl);
this._scaleControl = undefined;
}
if (!options || !this.leafletMap || !this.Leaflet) {
return;
}
this._scaleControl = this.Leaflet.control.scale({
position: "bottomleft",
metric: options.metric,
imperial: !options.metric,
});
this._scaleControl.addTo(this.leafletMap!);
}
public setView(center: MapLatLng, zoom?: number): void {
this.leafletMap?.setView(center, zoom);
}
public setZoom(zoom: number): void {
this.leafletMap?.setZoom(zoom);
}
private _getZoom(): number {
return this.leafletMap?.getZoom() ?? 0;
}
private _project(location: MapLatLng): { x: number; y: number } {
const point = this.leafletMap!.project(location, this._getZoom());
return { x: point.x, y: point.y };
}
public fitBounds(points: MapLatLng[], options?: MapFitOptions): void {
if (!this.leafletMap || !this.Leaflet || !points.length) {
return;
}
const bounds = this.Leaflet.latLngBounds(points).pad(options?.pad ?? 0.5);
this.leafletMap.fitBounds(bounds, {
maxZoom: options?.maxZoom,
animate: options?.animate,
});
}
public panTo(location: MapLatLng): void {
this.leafletMap?.panTo(location);
}
public containsLocation(location: MapLatLng): boolean {
return this.leafletMap?.getBounds().contains(location) ?? false;
}
public addMarker(
element: HTMLElement,
location: MapLatLng,
options: MapMarkerOptions
): MapMarkerHandle {
const decoration = options.decoration
? this.Leaflet!.circle(location, {
interactive: false,
color: options.decoration.color,
radius: options.decoration.radius,
})
: undefined;
// Leaflet's keyboard support focuses its own wrapper, where the element's
// activation handlers never hear a key; the element itself takes focus
const interactive = options.interactive ?? true;
const focusable = options.focusable ?? interactive;
setMarkerAccessibility(element, options.title, focusable);
const marker: HandledMarker = new DecoratedMarker(location, decoration, {
icon: this.Leaflet!.divIcon({
html: element,
iconSize: options.size,
iconAnchor: options.anchor,
className: "",
}),
interactive,
keyboard: false,
title: options.title,
});
const handle: LeafletMarkerHandle = {
marker,
location,
clusterData: options.clusterData,
remove: () => {
this._cluster?.removeLayer(marker);
marker.remove();
const index = this._clusterable.indexOf(marker);
if (index !== -1) {
this._clusterable.splice(index, 1);
}
},
};
marker.engineHandle = handle;
if (options.cluster) {
// Placed on the map by the next setClustering call
this._clusterable.push(marker);
} else {
marker.addTo(this.leafletMap!);
}
return handle;
}
public addCircle(
center: MapLatLng,
options: MapCircleOptions
): MapItemHandle {
const circle = this.Leaflet!.circle(center, {
interactive: false,
color: options.color,
radius: options.radius,
}).addTo(this.leafletMap!);
return { remove: () => circle.remove() };
}
public addPath(path: MapPath): MapItemHandle {
const items: (Polyline | CircleMarker)[] = [];
for (const segment of path.segments) {
items.push(
this.Leaflet!.polyline(segment.points, {
color: path.color,
opacity: segment.opacity,
interactive: false,
})
);
}
for (const pathMarker of path.markers) {
items.push(
this.Leaflet!.circleMarker(pathMarker.location, {
radius: isTouch ? 8 : 3,
color: path.color,
opacity: pathMarker.opacity,
fillOpacity: pathMarker.opacity,
interactive: true,
}).bindTooltip(pathMarker.tooltipHtml, { direction: "top" })
);
}
items.forEach((item) => item.addTo(this.leafletMap!));
return { remove: () => items.forEach((item) => item.remove()) };
}
public setClustering(options: MapClusterOptions | null): void {
if (this._cluster) {
this._cluster.remove();
this._cluster = undefined;
}
this._clusterOptions = options;
if (!this.leafletMap || !this.Leaflet) {
return;
}
if (!options) {
this._clusterable.forEach((marker) => marker.addTo(this.leafletMap!));
return;
}
// markercluster groups by proximity only; groupKey is not supported here
this._cluster = this.Leaflet.markerClusterGroup({
showCoverageOnHover: false,
removeOutsideVisibleBounds: false,
maxClusterRadius: options.radius,
iconCreateFunction: (cluster) => {
const members = (cluster.getAllChildMarkers() as HandledMarker[]).map(
(marker) => marker.engineHandle!
);
const latLng = cluster.getLatLng();
const icon = this._clusterOptions!.iconBuilder(members, [
latLng.lat,
latLng.lng,
]);
// The element fills the divIcon wrapper, which gets the size
icon.element.style.width = `${icon.size[0]}px`;
icon.element.style.height = `${icon.size[1]}px`;
// markercluster pins icons to the cluster, so a location override becomes an anchor shift
let anchor = icon.anchor;
if (icon.location) {
const clusterPoint = this._project([latLng.lat, latLng.lng]);
const targetPoint = this._project(icon.location);
const base = anchor ?? [icon.size[0] / 2, icon.size[1] / 2];
anchor = [
base[0] - (targetPoint.x - clusterPoint.x),
base[1] - (targetPoint.y - clusterPoint.y),
];
}
return this.Leaflet!.divIcon({
html: icon.element,
iconSize: icon.size,
iconAnchor: anchor,
className: "",
});
},
});
this._cluster.addLayers(this._clusterable);
this.leafletMap!.addLayer(this._cluster!);
}
public refreshClusters(): void {
this._cluster?.refreshClusters();
}
}
File diff suppressed because it is too large Load Diff
-302
View File
@@ -1,302 +0,0 @@
/**
* Map engine abstraction for ha-map: MapLibre GL where WebGL2 is available,
* Leaflet as the viewing fallback. The Leaflet engine is frozen at this
* contract; new capabilities go on MapLibre only, as optional members like
* `editing`.
*
* Positions are [latitude, longitude]; zoom levels use Leaflet semantics.
*/
export type MapLatLng = [latitude: number, longitude: number];
export type MapControlPosition =
"topleft" | "topright" | "bottomleft" | "bottomright";
export interface MapEngineEvents {
/** Click on the map surface, not on a marker */
click(location: MapLatLng): void;
/** Zoom is starting, programmatic or not */
zoomStart(): void;
/** The map starts moving, programmatic or not */
moveStart(): void;
/** The engine can no longer render; the host switches to the fallback */
fatal(): void;
}
export interface MapEngineOptions {
center: MapLatLng;
zoom: number;
darkMode: boolean;
/** Token for core's tile proxy */
token?: string;
zoomControlPosition: MapControlPosition;
/** Render without WebGL after a permanent context loss; WebGL engines reject init */
rasterOnly?: boolean;
events: Partial<MapEngineEvents>;
}
export interface MapFitOptions {
/** Do not zoom in beyond this level even if the bounds would allow it */
maxZoom?: number;
/** Relative padding around the bounds, e.g. 0.5 grows them by 50% */
pad?: number;
/** Ease the camera to the bounds instead of jumping; defaults to true */
animate?: boolean;
}
export interface MapMarkerOptions {
/** Rendered size of the element in pixels */
size: [width: number, height: number];
/** Point of the element placed on the coordinate, from its top left; defaults to the center */
anchor?: [x: number, y: number];
/** Takes pointer input; defaults to true */
interactive?: boolean;
/** A keyboard-focusable button, for markers that act on activation; defaults to interactive */
focusable?: boolean;
/** Accessible name */
title?: string;
/** A meter-radius circle sharing the marker's lifecycle (GPS accuracy) */
decoration?: MapCircleOptions;
/** Cluster this marker; it appears once setClustering is called */
cluster?: boolean;
/** Caller data handed back to the cluster icon builder */
clusterData?: unknown;
}
export interface MapCircleOptions {
/** Radius in meters */
radius: number;
/** Stroke color; the fill is derived from it, translucent */
color: string;
}
export interface MapPathSegment {
points: MapLatLng[];
opacity?: number;
}
export interface MapPathMarker {
location: MapLatLng;
opacity?: number;
/** Tooltip/popup HTML shown on hover; caller is responsible for escaping */
tooltipHtml: string;
}
export interface MapPath {
color: string;
segments: MapPathSegment[];
markers: MapPathMarker[];
}
/** Handle to anything placed on the map; remove() must be idempotent */
export interface MapItemHandle {
remove(): void;
}
export interface MapMarkerHandle extends MapItemHandle {
readonly location: MapLatLng;
readonly clusterData?: unknown;
}
export interface MapDraggableMarkerOptions extends MapMarkerOptions {
onDragEnd?(location: MapLatLng): void;
}
export interface MapEditableCircleOptions {
/** Radius in meters */
radius: number;
/** Stroke color; the fill is derived from it, translucent */
color: string;
/** Element shown at the center, e.g. the zone icon; a plain dot otherwise */
centerElement?: HTMLElement;
centerSize?: [width: number, height: number];
title?: string;
/** The center can be dragged */
moveable?: boolean;
/** A handle on the edge can be dragged to change the radius */
resizable?: boolean;
/** Accessible name of the radius handle, e.g. "Radius of Home in meters" */
resizeLabel?: string;
onMove?(center: MapLatLng): void;
onResize?(radius: number): void;
onClick?(): void;
}
export interface MapEditingSupport {
/** Place a draggable HTML element marker */
addDraggableMarker(
element: HTMLElement,
location: MapLatLng,
options: MapDraggableMarkerOptions
): MapEditableMarkerHandle;
/** Draw a circle whose center and radius can be dragged */
addEditableCircle(
center: MapLatLng,
options: MapEditableCircleOptions
): MapEditableCircleHandle;
}
/** A circle with drag handles for its center and radius */
export interface MapEditableCircleHandle extends MapItemHandle {
readonly center: MapLatLng;
readonly radius: number;
/** Move and resize without recreating (no-op mid-drag) */
update(center: MapLatLng, radius: number): void;
}
export interface MapEditableMarkerHandle extends MapMarkerHandle {
/** Move without recreating (no-op mid-drag) */
setLocation(location: MapLatLng): void;
}
export interface MapClusterIcon {
element: HTMLElement;
size: [width: number, height: number];
/** Like MapMarkerOptions.anchor; defaults to the element's center */
anchor?: [x: number, y: number];
/** Show the icon here instead of at the cluster, e.g. attached to a zone */
location?: MapLatLng;
}
export interface MapClusterOptions {
/** Cluster markers closer than this many screen pixels */
radius: number;
/**
* Markers sharing a key (e.g. their zone) form one group while they span
* at most groupRadius pixels; beyond that, and without a key, they cluster
* by proximity.
*/
groupKey?(marker: MapMarkerHandle): string | undefined;
groupRadius?: number;
/** Builds a cluster's element; called when its members change and on refreshClusters() */
iconBuilder(members: MapMarkerHandle[], location: MapLatLng): MapClusterIcon;
}
export interface MapEngine {
/** Create the map in the container; call once */
init(container: HTMLElement, options: MapEngineOptions): Promise<void>;
/** Tear down the map and release its resources (DOM, workers, WebGL) */
destroy(): void;
/** Re-measure the container after a size change */
invalidateSize(): void;
/** Whether the map has a non-zero size, re-measuring if needed */
hasUsableSize(): boolean;
setDarkMode(darkMode: boolean): void;
setZoomControlPosition(position: MapControlPosition): void;
/** Show a scale ruler (bottom start); null hides it */
setScaleRuler(options: { metric: boolean } | null): void;
setView(center: MapLatLng, zoom?: number): void;
setZoom(zoom: number): void;
/** Fit the given points into view; a single point centers on it */
fitBounds(points: MapLatLng[], options?: MapFitOptions): void;
/** Pan to the location, keeping the zoom */
panTo(location: MapLatLng): void;
/** Whether the location is inside the current viewport */
containsLocation(location: MapLatLng): boolean;
/** Place a caller-owned element on the map */
addMarker(
element: HTMLElement,
location: MapLatLng,
options: MapMarkerOptions
): MapMarkerHandle;
/** Draw a meter-radius circle (zone radius) */
addCircle(center: MapLatLng, options: MapCircleOptions): MapItemHandle;
/** Editing support, MapLibre only; undefined on the Leaflet fallback */
editing?: MapEditingSupport;
/** Draw one history trail (points with tooltips, connecting segments) */
addPath(path: MapPath): MapItemHandle;
/** Cluster the markers added with cluster: true; call after each batch of addMarker calls */
setClustering(options: MapClusterOptions | null): void;
/** Rebuild cluster icons without regrouping (e.g. after a style change) */
refreshClusters(): void;
}
const EARTH_RADIUS = 6371008.8;
const toRadians = (degrees: number) => (degrees * Math.PI) / 180;
const toDegrees = (radians: number) => (radians * 180) / Math.PI;
/**
* The point a distance away from center along a bearing (degrees clockwise
* from north), on the great circle. Longitude is left unwrapped so a ring of
* points stays continuous across the antimeridian.
*/
export const destinationPoint = (
center: MapLatLng,
distanceInMeters: number,
bearingDegrees: number
): MapLatLng => {
const angular = distanceInMeters / EARTH_RADIUS;
const lat = toRadians(center[0]);
const bearing = toRadians(bearingDegrees);
const destLat = Math.asin(
Math.sin(lat) * Math.cos(angular) +
Math.cos(lat) * Math.sin(angular) * Math.cos(bearing)
);
const dLng = Math.atan2(
Math.sin(bearing) * Math.sin(angular) * Math.cos(lat),
Math.cos(angular) - Math.sin(lat) * Math.sin(destLat)
);
return [toDegrees(destLat), center[1] + toDegrees(dLng)];
};
/** Great-circle distance in meters */
export const distanceMeters = (a: MapLatLng, b: MapLatLng): number => {
const dLat = toRadians(b[0] - a[0]);
const dLng = toRadians(b[1] - a[1]);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRadians(a[0])) *
Math.cos(toRadians(b[0])) *
Math.sin(dLng / 2) ** 2;
return 2 * EARTH_RADIUS * Math.asin(Math.sqrt(h));
};
/** The point the given distance due east of center, e.g. for a resize handle */
export const pointEastOf = (
center: MapLatLng,
distanceInMeters: number
): MapLatLng => destinationPoint(center, distanceInMeters, 90);
/**
* Bounding box corners of a circle, for fitting a radius into view. A circle
* that reaches a pole spans every longitude.
*/
export const circleBoundsPoints = (
center: MapLatLng,
radiusMeters: number
): MapLatLng[] => {
const angular = radiusMeters / EARTH_RADIUS;
const latMin = Math.max(-90, center[0] - toDegrees(angular));
const latMax = Math.min(90, center[0] + toDegrees(angular));
const sinRatio = Math.sin(angular) / Math.cos(toRadians(center[0]));
if (latMin <= -90 || latMax >= 90 || Math.abs(sinRatio) >= 1) {
return [
[latMin, -180],
[latMax, 180],
];
}
const dLng = toDegrees(Math.asin(sinRatio));
return [
[latMin, center[1] - dLng],
[latMax, center[1] + dLng],
];
};
-53
View File
@@ -1,53 +0,0 @@
/**
* Marker elements may be focusable buttons on both engines, so the caller's
* element is the keyboard target and needs a name and a role. MapLibre would
* otherwise label it "Map marker".
*
* What the engine sets is recorded on the element, so a rebuilt marker on a
* reused element gets fresh attributes while the caller's own are left alone.
*/
const OWNED = "haMapA11y";
export const setMarkerAccessibility = (
element: HTMLElement,
title: string | undefined,
focusable: boolean
): void => {
clearMarkerAccessibility(element);
const owned: string[] = [];
if (title && !element.hasAttribute("aria-label")) {
element.setAttribute("aria-label", title);
owned.push("aria-label");
}
if (!element.hasAttribute("role")) {
if (focusable) {
element.setAttribute("role", "button");
owned.push("role");
} else if (title) {
element.setAttribute("role", "img");
owned.push("role");
} else {
element.setAttribute("aria-hidden", "true");
owned.push("aria-hidden");
}
}
if (focusable && !element.hasAttribute("tabindex")) {
element.tabIndex = 0;
owned.push("tabindex");
}
element.dataset[OWNED] = owned.join(" ");
};
/** Removes what setMarkerAccessibility set on the element */
export const clearMarkerAccessibility = (element: HTMLElement): void => {
const owned = element.dataset[OWNED];
if (owned === undefined) {
return;
}
owned
.split(" ")
.filter(Boolean)
.forEach((name) => element.removeAttribute(name));
delete element.dataset[OWNED];
};
+16 -54
View File
@@ -43,8 +43,6 @@ const OVERFLOW_MARGIN = 5;
const FONT_SIZE = 12;
const NODE_GAP = 6;
const LABEL_DISTANCE = 5;
const LABEL_MIN_MARGIN = 5;
const BIDI_MARKS = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
@customElement("ha-sankey-chart")
export class HaSankeyChart extends LitElement {
@@ -57,9 +55,6 @@ export class HaSankeyChart extends LitElement {
@property({ type: Boolean }) public vertical = false;
@property({ type: Boolean, attribute: "show-values" }) public showValues =
false;
@property({ attribute: false }) public valueFormatter?: (
value: number
) => string;
@@ -89,11 +84,7 @@ export class HaSankeyChart extends LitElement {
return html`<ha-chart-base
.hass=${this.hass}
.data=${this._createData(
this.data,
this._sizeController.value?.width,
this.showValues
)}
.data=${this._createData(this.data, this._sizeController.value?.width)}
.options=${options}
height="100%"
.extraComponents=${[SankeyChart]}
@@ -151,11 +142,7 @@ export class HaSankeyChart extends LitElement {
}
};
private _computeData = (
data: SankeyChartData,
width = 0,
showValues = false
) => {
private _createData = memoizeOne((data: SankeyChartData, width = 0) => {
const filteredNodes = data.nodes.filter((n) => n.value > 0);
const indexes = [...new Set(filteredNodes.map((n) => n.index))].sort();
const depthMap = new Map<number, number>();
@@ -196,10 +183,6 @@ export class HaSankeyChart extends LitElement {
const links = this._processLinks(filteredNodes, data.links);
const sectionWidth = width / indexes.length;
const labelSpace = sectionWidth - NODE_SIZE - LABEL_DISTANCE;
// Two-line values can wrap the unit onto a third line; keep that text inside the chart.
const verticalBottom = showValues
? LABEL_DISTANCE + FONT_SIZE * 3 + OVERFLOW_MARGIN
: 25;
return {
id: "sankey",
@@ -226,48 +209,29 @@ export class HaSankeyChart extends LitElement {
layoutIterations: 0,
animationDuration: 500,
label: {
formatter: (params) => {
const nodeData = params.data as { id: string; value: number };
const node = data.nodes.find((n) => n.id === nodeData.id);
const label = node?.label ?? nodeData.id;
if (!showValues || !nodeData.id) return label;
const formatted = this.valueFormatter
? this.valueFormatter(nodeData.value).trim()
: String(nodeData.value);
// LRM keeps numeric values LTR on the canvas without creating wrap points.
return `${label}\n\u200E${formatted}`;
},
formatter: (params) =>
data.nodes.find((node) => node.id === (params.data as Node).id)
?.label ?? (params.data as Node).id,
position: this.vertical ? "bottom" : "right",
distance: LABEL_DISTANCE,
minMargin: LABEL_MIN_MARGIN,
minMargin: 5,
overflow: "break",
},
labelLayout: (params) => {
if (this.vertical) {
// reduce the label font size so the longest word fits on one line
const longestWord = params.text
.replace(BIDI_MARKS, "")
.split(/[ \n]+/)
.reduce((longest, current) => {
if (!current) {
return longest;
}
if (!longest) {
return current;
}
return measureTextWidth(current, FONT_SIZE) >
measureTextWidth(longest, FONT_SIZE)
? current
: longest;
}, "");
const wordWidth = measureTextWidth(longestWord, FONT_SIZE) || 1;
.split(" ")
.reduce(
(longest, current) =>
longest.length > current.length ? longest : current,
""
);
const wordWidth = measureTextWidth(longestWord, FONT_SIZE);
const availableWidth = (params.rect.width + 6) * this._currentZoom;
// minMargin is applied as padding on the label box, so words must
// fit in the inner wrap width or overflow:break splits them.
const wrapWidth = Math.max(availableWidth - LABEL_MIN_MARGIN, 1);
const fontSize = Math.min(
FONT_SIZE,
(wrapWidth / wordWidth) * FONT_SIZE
(availableWidth / wordWidth) * FONT_SIZE
);
return {
fontSize: fontSize > 1 ? fontSize : 0,
@@ -290,16 +254,14 @@ export class HaSankeyChart extends LitElement {
};
},
top: this.vertical ? 0 : OVERFLOW_MARGIN,
bottom: this.vertical ? verticalBottom : OVERFLOW_MARGIN,
bottom: this.vertical ? 25 : OVERFLOW_MARGIN,
left: this.vertical ? OVERFLOW_MARGIN : 0,
right: this.vertical ? OVERFLOW_MARGIN : labelSpace + LABEL_DISTANCE,
emphasis: {
focus: "adjacency",
},
} as SankeySeriesOption;
};
private _createData = memoizeOne(this._computeData);
});
private _processLinks(nodes: Node[], rawLinks: Link[]) {
const accountedIn = new Map<string, number>();
@@ -1,8 +1,8 @@
import memoizeOne from "memoize-one";
import { mdiChartBellCurveCumulative } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { mdiChartBellCurveCumulative } from "@mdi/js";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import type {
NumericThresholdSelector,
@@ -28,14 +28,14 @@ const iconThresholdOutside =
type ThresholdType = "above" | "below" | "between" | "outside" | "any";
interface ThresholdValueEntry {
export interface ThresholdValueEntry {
active_choice?: string;
number?: number;
entity?: string;
unit_of_measurement?: string;
}
interface NumericThresholdValue {
export interface NumericThresholdValue {
type: ThresholdType;
value?: ThresholdValueEntry;
value_min?: ThresholdValueEntry;
-18
View File
@@ -58,24 +58,6 @@ class HaEntityMarker extends LitElement {
`;
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("keydown", this._handleKeydown);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("keydown", this._handleKeydown);
}
// The map engines make the marker a focusable button
private _handleKeydown = (ev: KeyboardEvent) => {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._badgeTap(ev);
}
};
private _badgeTap(ev: Event) {
ev.stopPropagation();
if (this.entityId) {
+234 -160
View File
@@ -1,19 +1,22 @@
import { consume } from "@lit/context";
import type {
Circle,
DivIcon,
DragEndEvent,
LatLng,
LatLngExpression,
Marker,
MarkerOptions,
} from "leaflet";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { MAP_MAX_ZOOM } from "../../common/map/base-layer";
import type { MapLatLng } from "../../common/map/map-engine";
import { circleBoundsPoints } from "../../common/map/map-engine";
import { internationalizationContext } from "../../data/context";
import type { HomeAssistantInternationalization, ThemeMode } from "../../types";
import "../ha-alert";
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
import type { ThemeMode } from "../../types";
import "../ha-input-helper-text";
import "./ha-map";
import type { HaMap, HaMapEditableLocation } from "./ha-map";
import type { HaMap } from "./ha-map";
import type { HaIcon } from "../ha-icon";
import type { HaSvgIcon } from "../ha-svg-icon";
@@ -38,16 +41,8 @@ export interface MarkerLocation {
radius_color?: string;
location_editable?: boolean;
radius_editable?: boolean;
/** Clicking or activating the marker fires marker-clicked */
clickable?: boolean;
}
const ICON_SIZE = 24;
/**
* A map with draggable markers and zone circles, drawn by ha-map. Without
* editing support (the Leaflet fallback) they are static, with a notice.
*/
@customElement("ha-locations-editor")
export class HaLocationsEditor extends LitElement {
@property({ attribute: false }) public locations?: MarkerLocation[];
@@ -64,20 +59,35 @@ export class HaLocationsEditor extends LitElement {
@property({ type: Boolean, attribute: "pin-on-click" })
public pinOnClick = false;
@state() private _locationMarkers?: Record<string, Marker | Circle>;
@state() private _circles: Record<string, Circle> = {};
@query("ha-map", true) private map!: HaMap;
@state() private _editingAvailable = true;
private Leaflet?: LeafletModuleType;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
private _loadPromise: Promise<boolean | undefined | void>;
constructor() {
super();
this._loadPromise = import("leaflet").then((module) =>
import("leaflet-draw").then(() => {
this.Leaflet = module.default as LeafletModuleType;
this._updateMarkers();
return this.updateComplete.then(() => this.fitMap());
})
);
}
public fitMap(options?: { zoom?: number; pad?: number }): void {
this.map.fitMap(options);
}
public fitBounds(
boundingbox: MapLatLng[],
boundingbox: LatLngExpression[],
options?: { zoom?: number; pad?: number }
) {
this.map.fitBounds(boundingbox, options);
@@ -87,50 +97,42 @@ export class HaLocationsEditor extends LitElement {
id: string,
options?: { zoom?: number }
): Promise<void> {
await this.updateComplete;
const location = this.locations?.find((loc) => loc.id === id);
if (!location) {
if (!this.Leaflet) {
await this._loadPromise;
}
if (!this.map.leafletMap || !this._locationMarkers) {
return;
}
const center: MapLatLng = [location.latitude, location.longitude];
if (location.radius) {
// Only the map's maximum caps the zoom, however small the zone
this.map.fitBounds(circleBoundsPoints(center, location.radius), {
pad: 0,
zoom: MAP_MAX_ZOOM,
});
const marker = this._locationMarkers[id];
if (!marker) {
return;
}
if ("getBounds" in marker) {
this.map.leafletMap.fitBounds(marker.getBounds());
(marker as Circle).bringToFront();
} else {
this.map.setView(center, options?.zoom || this.zoom);
const circle = this._circles[id];
if (circle) {
this.map.leafletMap.fitBounds(circle.getBounds());
} else {
this.map.leafletMap.setView(
marker.getLatLng(),
options?.zoom || this.zoom
);
}
}
}
protected render(): TemplateResult {
return html`
<div class="map">
<ha-map
.editableLocations=${this._editableLocations(this.locations)}
.zoom=${this.zoom}
.autoFit=${this.autoFit}
.themeMode=${this.themeMode}
.clickable=${this.pinOnClick}
@map-clicked=${this._mapClicked}
@editable-location-moved=${this._locationMoved}
@editable-location-resized=${this._radiusChanged}
@editable-location-clicked=${this._markerClicked}
@editing-available-changed=${this._editingAvailableChanged}
></ha-map>
${
!this._editingAvailable &&
this._i18n &&
this.locations?.some(
(location) => location.location_editable || location.radius_editable
)
? html`<ha-alert alert-type="warning">
${this._i18n.localize("ui.components.map.editing_unavailable")}
</ha-alert>`
: ""
}
</div>
<ha-map
.layers=${this._getLayers(this._circles, this._locationMarkers)}
.zoom=${this.zoom}
.autoFit=${this.autoFit}
.themeMode=${this.themeMode}
.clickable=${this.pinOnClick}
@map-clicked=${this._mapClicked}
></ha-map>
${
this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
@@ -139,102 +141,63 @@ export class HaLocationsEditor extends LitElement {
`;
}
private _editableLocations = memoizeOne(
(locations?: MarkerLocation[]): HaMapEditableLocation[] => {
const ids = new Set((locations ?? []).map((location) => location.id));
for (const id of this._elements.keys()) {
if (!ids.has(id)) {
this._elements.delete(id);
}
private _getLayers = memoizeOne(
(
circles: Record<string, Circle>,
markers?: Record<string, Marker | Circle>
): (Marker | Circle)[] => {
const layers: (Marker | Circle)[] = [];
Array.prototype.push.apply(layers, Object.values(circles));
if (markers) {
Array.prototype.push.apply(layers, Object.values(markers));
}
return (locations ?? []).map((location) => ({
id: location.id,
location: [location.latitude, location.longitude],
radius: location.radius,
element: this._elementFor(location),
elementSize: [ICON_SIZE, ICON_SIZE],
title: location.name,
color: location.radius_color,
locationEditable: location.location_editable,
radiusEditable: location.radius_editable,
activatable: location.clickable,
}));
return layers;
}
);
// Reused while unchanged, so ha-map moves markers instead of rebuilding them
private _elements = new Map<string, { key: string; element?: HTMLElement }>();
public willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
private _elementFor(location: MarkerLocation): HTMLElement | undefined {
const key = JSON.stringify([
location.icon,
location.iconPath,
location.name,
location.location_editable,
]);
const cached = this._elements.get(location.id);
if (cached?.key === key) {
return cached.element;
// Still loading.
if (!this.Leaflet) {
return;
}
const element = this._createIcon(location);
this._elements.set(location.id, { key, element });
return element;
}
private _createIcon(location: MarkerLocation): HTMLElement | undefined {
if (!location.icon && !location.iconPath) {
return undefined;
if (changedProps.has("locations")) {
this._updateMarkers();
}
const el = document.createElement("div");
el.className = `named-icon ${
location.location_editable ? "draggable" : ""
}`;
if (location.name !== undefined) {
el.innerText = location.name;
}
let iconEl: HaIcon | HaSvgIcon;
if (location.icon) {
iconEl = document.createElement("ha-icon");
iconEl.setAttribute("icon", location.icon);
} else {
iconEl = document.createElement("ha-svg-icon");
iconEl.setAttribute("path", location.iconPath!);
}
el.prepend(iconEl);
return el;
}
public updated(changedProps: PropertyValues): void {
if (changedProps.has("locations")) {
fireEvent(this, "markers-updated");
// Still loading.
if (!this.Leaflet) {
return;
}
// Follow a location that was edited out of view
const oldLocations = changedProps.get("locations") as
MarkerLocation[] | undefined;
if (changedProps.has("locations")) {
const oldLocations = changedProps.get("locations");
const movedLocations = this.locations?.filter(
(loc, idx) =>
!oldLocations?.[idx] ||
!oldLocations[idx] ||
((loc.latitude !== oldLocations[idx].latitude ||
loc.longitude !== oldLocations[idx].longitude) &&
this.map.containsLocation([
oldLocations[idx].latitude,
oldLocations[idx].longitude,
]) &&
!this.map.containsLocation([loc.latitude, loc.longitude]))
this.map.leafletMap?.getBounds().contains({
lat: oldLocations[idx].latitude,
lng: oldLocations[idx].longitude,
}) &&
!this.map.leafletMap
?.getBounds()
.contains({ lat: loc.latitude, lng: loc.longitude }))
);
if (movedLocations?.length === 1) {
this.map.panTo([
movedLocations[0].latitude,
movedLocations[0].longitude,
]);
this.map.leafletMap?.panTo({
lat: movedLocations[0].latitude,
lng: movedLocations[0].longitude,
});
}
}
}
private _editingAvailableChanged(ev: HASSDomEvent<{ available: boolean }>) {
this._editingAvailable = ev.detail.available;
}
private _normalizeLongitude(longitude: number): number {
if (Math.abs(longitude) > 180.0) {
// Normalize longitude if map provides values beyond -180 to +180 degrees.
@@ -243,37 +206,40 @@ export class HaLocationsEditor extends LitElement {
return longitude;
}
private _locationMoved(
ev: HASSDomEvent<{ id: string; location: MapLatLng }>
) {
const [latitude, longitude] = ev.detail.location;
private _updateLocation(ev: DragEndEvent) {
const marker = ev.target;
const latlng: LatLng = marker.getLatLng();
const location: [number, number] = [
latlng.lat,
this._normalizeLongitude(latlng.lng),
];
fireEvent(
this,
"location-updated",
{
id: ev.detail.id,
location: [latitude, this._normalizeLongitude(longitude)],
},
{ id: marker.id, location },
{ bubbles: false }
);
}
private _radiusChanged(ev: HASSDomEvent<{ id: string; radius: number }>) {
private _updateRadius(ev: DragEndEvent) {
const marker = ev.target;
const circle = this._locationMarkers![marker.id] as Circle;
fireEvent(
this,
"radius-updated",
{ id: ev.detail.id, radius: ev.detail.radius },
{ id: marker.id, radius: circle.getRadius() },
{ bubbles: false }
);
}
private _markerClicked(ev: HASSDomEvent<{ id: string }>) {
fireEvent(this, "marker-clicked", { id: ev.detail.id }, { bubbles: false });
private _markerClicked(ev: DragEndEvent) {
const marker = ev.target;
fireEvent(this, "marker-clicked", { id: marker.id }, { bubbles: false });
}
private _mapClicked(ev: HASSDomEvent<{ location: [number, number] }>) {
if (this.pinOnClick && this.locations?.length) {
const id = this.locations[0].id;
private _mapClicked(ev) {
if (this.pinOnClick && this._locationMarkers) {
const id = Object.keys(this._locationMarkers)[0];
const location: [number, number] = [
ev.detail.location[0],
this._normalizeLongitude(ev.detail.location[1]),
@@ -282,28 +248,136 @@ export class HaLocationsEditor extends LitElement {
// If the normalized longitude wraps around the globe, pan to the new location.
if (location[1] !== ev.detail.location[1]) {
this.map.panTo(location);
this.map.leafletMap?.panTo({ lat: location[0], lng: location[1] });
}
}
}
static styles = css`
.map {
position: relative;
height: 100%;
private _updateMarkers(): void {
if (!this.locations || !this.locations.length) {
this._circles = {};
this._locationMarkers = undefined;
return;
}
const locationMarkers = {};
const circles = {};
const defaultZoneRadiusColor =
getComputedStyle(this).getPropertyValue("--accent-color");
this.locations.forEach((location: MarkerLocation) => {
let icon: DivIcon | undefined;
if (location.icon || location.iconPath) {
// create icon
const el = document.createElement("div");
el.className = "named-icon";
if (location.name !== undefined) {
el.innerText = location.name;
}
let iconEl: HaIcon | HaSvgIcon;
if (location.icon) {
iconEl = document.createElement("ha-icon");
iconEl.setAttribute("icon", location.icon);
} else {
iconEl = document.createElement("ha-svg-icon");
iconEl.setAttribute("path", location.iconPath!);
}
el.prepend(iconEl);
icon = this.Leaflet!.divIcon({
html: el.outerHTML,
iconSize: [24, 24],
className: "light",
});
}
if (location.radius) {
const circle = this.Leaflet!.circle(
[location.latitude, location.longitude],
{
color: location.radius_color || defaultZoneRadiusColor,
radius: location.radius,
}
);
if (location.radius_editable || location.location_editable) {
// @ts-ignore
circle.editing.enable();
circle.addEventListener("add", () => {
// @ts-ignore
const moveMarker = circle.editing._moveMarker;
// @ts-ignore
const resizeMarker = circle.editing._resizeMarkers[0];
if (icon) {
moveMarker.setIcon(icon);
}
resizeMarker.id = moveMarker.id = location.id;
moveMarker
.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._updateLocation(ev)
)
.addEventListener(
"click",
// @ts-ignore
(ev: MouseEvent) => this._markerClicked(ev)
);
if (location.radius_editable) {
resizeMarker.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._updateRadius(ev)
);
} else {
resizeMarker.remove();
}
});
locationMarkers[location.id] = circle;
} else {
circles[location.id] = circle;
}
}
if (
!location.radius ||
(!location.radius_editable && !location.location_editable)
) {
const options: MarkerOptions = {
title: location.name,
draggable: location.location_editable,
};
if (icon) {
options.icon = icon;
}
const marker = this.Leaflet!.marker(
[location.latitude, location.longitude],
options
)
.addEventListener("dragend", (ev: DragEndEvent) =>
this._updateLocation(ev)
)
.addEventListener(
// @ts-ignore
"click",
// @ts-ignore
(ev: MouseEvent) => this._markerClicked(ev)
);
(marker as any).id = location.id;
locationMarkers[location.id] = marker;
}
});
this._circles = circles;
this._locationMarkers = locationMarkers;
fireEvent(this, "markers-updated");
}
static styles = css`
ha-map {
display: block;
height: 100%;
}
/* Over the map, clear of the zoom control, so a fixed-height host shows it */
ha-alert {
position: absolute;
top: var(--ha-space-2);
inset-inline-start: 56px;
inset-inline-end: var(--ha-space-2);
z-index: 1;
}
`;
}
File diff suppressed because it is too large Load Diff
-2
View File
@@ -663,8 +663,6 @@ export const subscribeCondition = (
onChange: (result: {
result?: boolean;
error?: string | { code: string; message: string };
/** Template errors while still producing a result. */
template_errors?: string[];
}) => void,
condition: Condition
) =>
+2 -2
View File
@@ -1,9 +1,9 @@
import type { VisibilityCondition } from "../../../panels/lovelace/common/validate-condition";
import type { Condition } from "../../../panels/lovelace/common/validate-condition";
export interface LovelaceBadgeConfig {
type: string;
[key: string]: any;
visibility?: VisibilityCondition[];
visibility?: Condition[];
disabled?: boolean;
}
+2 -2
View File
@@ -1,4 +1,4 @@
import type { VisibilityCondition } from "../../../panels/lovelace/common/validate-condition";
import type { Condition } from "../../../panels/lovelace/common/validate-condition";
import type {
LovelaceGridOptions,
LovelaceLayoutOptions,
@@ -13,6 +13,6 @@ export interface LovelaceCardConfig {
grid_options?: LovelaceGridOptions;
type: string;
[key: string]: any;
visibility?: VisibilityCondition[];
visibility?: Condition[];
disabled?: boolean;
}
+2 -2
View File
@@ -1,4 +1,4 @@
import type { VisibilityCondition } from "../../../panels/lovelace/common/validate-condition";
import type { Condition } from "../../../panels/lovelace/common/validate-condition";
import type { LovelaceCardConfig } from "./card";
import type { LovelaceStrategyConfig } from "./strategy";
@@ -10,7 +10,7 @@ export interface LovelaceSectionBackgroundConfig {
}
export interface LovelaceBaseSectionConfig {
visibility?: VisibilityCondition[];
visibility?: Condition[];
disabled?: boolean;
column_span?: number;
row_span?: number;
+2 -2
View File
@@ -1,5 +1,5 @@
import { titleCase } from "../../../common/string/title-case";
import type { VisibilityCondition } from "../../../panels/lovelace/common/validate-condition";
import type { Condition } from "../../../panels/lovelace/common/validate-condition";
import type { MediaSelectorValue } from "../../selector";
import type { LovelaceBadgeConfig } from "./badge";
import type { LovelaceCardConfig } from "./card";
@@ -52,7 +52,7 @@ export interface LovelaceViewSidebarConfig {
sections?: LovelaceSectionConfig[];
content_label?: string;
sidebar_label?: string;
visibility?: VisibilityCondition[];
visibility?: Condition[];
}
export interface LovelaceBaseViewConfig {
-32
View File
@@ -1,32 +0,0 @@
import type { HomeAssistant } from "../types";
/** Transport with host and port, or with the serial device path. */
export type ModbusEndpoint = [string, string, number] | [string, string];
export interface ModbusConnection {
endpoint: ModbusEndpoint;
connected: boolean;
/** The unit IDs each config entry holds, keyed by config entry ID. */
units: Record<string, number[]>;
}
export const listModbusConnections = async (
hass: HomeAssistant
): Promise<ModbusConnection[]> =>
(
await hass.callWS<{ connections: ModbusConnection[] }>({
type: "modbus/connections/list",
})
).connections;
/** The serial port a connection runs over, if it is not a network connection. */
export const modbusSerialDevice = (
endpoint: ModbusEndpoint
): string | undefined => (endpoint[0] === "serial" ? endpoint[1] : undefined);
/** The address of the device, as `host:port` or as a device path. */
export const modbusEndpointTarget = (endpoint: ModbusEndpoint): string =>
endpoint.length === 3 ? `${endpoint[1]}:${endpoint[2]}` : endpoint[1];
export const modbusUnitCount = (connection: ModbusConnection): number =>
Object.values(connection.units).reduce((total, ids) => total + ids.length, 0);
@@ -1,4 +1,3 @@
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
import { consume } from "@lit/context";
import type { HassConfig } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
@@ -11,7 +10,6 @@ import { transform } from "../../../common/decorators/transform";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import "../../../components/animation/ha-fade-in";
import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -93,12 +91,8 @@ class MoreInfoUpdate extends LitElement {
@state() private _markdownLoading = true;
@state() private _backupConfigLoading = true;
@state() private _backupConfig?: BackupConfig;
@state() private _createBackupLoading = true;
@state() private _createBackup = false;
@state() private _entitySources?: EntitySources;
@@ -111,8 +105,6 @@ class MoreInfoUpdate extends LitElement {
// ignore error, because user will get a manual backup option
// eslint-disable-next-line no-console
console.error(err);
} finally {
this._backupConfigLoading = false;
}
}
@@ -123,7 +115,6 @@ class MoreInfoUpdate extends LitElement {
// for home assistant and OS updates
if (this._isHaOrOsUpdate(type)) {
this._createBackup = config.core_backup_before_update;
this._createBackupLoading = false;
return;
}
@@ -134,9 +125,6 @@ class MoreInfoUpdate extends LitElement {
// ignore error, because user can still set the config
// eslint-disable-next-line no-console
console.error(err);
this._createBackup = false;
} finally {
this._createBackupLoading = false;
}
}
@@ -165,10 +153,6 @@ class MoreInfoUpdate extends LitElement {
: "generic";
if (this._isHaOrOsUpdate(updateType)) {
if (this._backupConfigLoading) {
return undefined;
}
const isBackupConfigValid =
!!this._backupConfig &&
!!this._backupConfig.automatic_backups_configured &&
@@ -348,34 +332,25 @@ class MoreInfoUpdate extends LitElement {
</div>
<div class="footer">
${
createBackupTexts || this._backupConfigLoading
createBackupTexts
? html`
<ha-row-item
.headline=${createBackupTexts ? createBackupTexts.title : undefined}
.supportingText=${createBackupTexts ? createBackupTexts.description : undefined}
>
<ha-row-item>
<span slot="headline">${createBackupTexts.title}</span>
${
!createBackupTexts
? html`<ha-fade-in slot="headline" .delay=${500}
><wa-skeleton effect="sheen"></wa-skeleton
></ha-fade-in>`
createBackupTexts.description
? html`
<span slot="supporting-text">
${createBackupTexts.description}
</span>
`
: nothing
}
${
this._createBackupLoading
? html`<ha-fade-in
class="skeleton-end"
slot="end"
.delay=${500}
><wa-skeleton effect="sheen"></wa-skeleton
></ha-fade-in>`
: html`<ha-switch
slot="end"
.checked=${this._createBackup}
@change=${this._createBackupChanged}
.disabled=${updateIsInstalling(this.stateObj)}
></ha-switch>`
}
<ha-switch
slot="end"
.checked=${this._createBackup}
@change=${this._createBackupChanged}
.disabled=${updateIsInstalling(this.stateObj)}
></ha-switch>
</ha-row-item>
`
: nothing
@@ -441,34 +416,19 @@ class MoreInfoUpdate extends LitElement {
this._fetchReleaseNotes();
}
if (supportsFeature(this.stateObj!, UpdateEntityFeature.BACKUP)) {
this._fetchEntitySources()
.then(() => {
const type = getUpdateType(this.stateObj!, this._entitySources!);
if (
isComponentLoaded(this._config, "hassio") &&
["addon", "home_assistant", "home_assistant_os"].includes(type)
) {
this._fetchUpdateBackupConfig(type);
} else {
this._createBackupLoading = false;
}
this._fetchEntitySources().then(() => {
const type = getUpdateType(this.stateObj!, this._entitySources!);
if (
isComponentLoaded(this._config, "hassio") &&
["addon", "home_assistant", "home_assistant_os"].includes(type)
) {
this._fetchUpdateBackupConfig(type);
}
if (this._isHaOrOsUpdate(type)) {
this._fetchBackupConfig();
} else {
this._backupConfigLoading = false;
}
})
.catch((err) => {
// ignore error, because the generic backup option remains available
// eslint-disable-next-line no-console
console.error(err);
this._createBackupLoading = false;
this._backupConfigLoading = false;
});
} else {
this._createBackupLoading = false;
this._backupConfigLoading = false;
if (this._isHaOrOsUpdate(type)) {
this._fetchBackupConfig();
}
});
}
}
@@ -627,11 +587,6 @@ class MoreInfoUpdate extends LitElement {
box-sizing: border-box;
padding-bottom: var(--ha-space-4);
}
.skeleton-end {
width: 48px;
height: 24px;
display: block;
}
`;
}
+75 -110
View File
@@ -1,16 +1,13 @@
import { consume } from "@lit/context";
import type { PropertyValues, ReactiveElement } from "lit";
import { state } from "lit/decorators";
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 { evaluateConditionsLocally } from "../common/condition/evaluate-locally";
import type {
ConditionContext,
VisibilityCondition,
} from "../panels/lovelace/common/validate-condition";
import { addEntityToCondition } from "../panels/lovelace/common/validate-condition";
import type { HomeAssistant } from "../types";
import { setupConditionListeners } from "../common/condition/listeners";
import { maxColumnsContext } from "../panels/lovelace/common/context";
import type {
Condition,
ConditionContext,
} from "../panels/lovelace/common/validate-condition";
type Constructor<T> = abstract new (...args: any[]) => T;
@@ -18,19 +15,27 @@ type Constructor<T> = abstract new (...args: any[]) => T;
* Base config type that can be used with conditional listeners
*/
export interface ConditionalConfig {
visibility?: VisibilityCondition[];
visibility?: Condition[];
[key: string]: any;
}
/**
* Mixin for dashboard visibility.
* Mixin to handle conditional listeners for visibility control
*
* Stateful conditions go to core via `subscribe_condition`; screen/user/time
* stay local. Call `_conditionsVisible()` from `_updateVisibility` /
* `_updateElement`.
* Provides lifecycle management for listeners that control conditional
* visibility of components.
*
* Override `setupConditionalListeners()` to pass a custom list (e.g. a
* conditional card's `conditions`).
* 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)
*
* 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
*/
export const ConditionalListenerMixin = <
TConfig extends ConditionalConfig = ConditionalConfig,
@@ -38,6 +43,8 @@ export const ConditionalListenerMixin = <
superClass: Constructor<ReactiveElement>
) => {
abstract class ConditionalListenerClass extends superClass {
private __listeners: (() => void)[] = [];
protected _config?: TConfig;
public config?: TConfig;
@@ -50,40 +57,6 @@ export const ConditionalListenerMixin = <
protected _conditionContext: ConditionContext = {};
// What the evaluator is currently watching; used for the local seed.
private __conditions?: VisibilityCondition[];
// `unknown` until a server subtree reports (or immediately if all client).
private __conditionResult: ConditionEvaluation = "unknown";
// Folded conditions, rebuilt only when the source or entity id changes.
private __observedSource?: VisibilityCondition[];
private __observedEntityId?: string;
private __observed?: VisibilityCondition[];
// Drop the cached verdict when the tree content changes.
private __conditionsSignature?: string;
private __conditionEvaluator = new ConditionEvaluatorController(this, {
// Local seed covers the first frame; no need to debounce.
resubscribeDelay: 0,
onResult: (result) => {
this.__conditionResult = result;
// We set visibility ourselves; ignore the disconnect `unknown`.
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;
@@ -93,6 +66,11 @@ export const ConditionalListenerMixin = <
this.setupConditionalListeners();
}
public disconnectedCallback() {
super.disconnectedCallback();
this.clearConditionalListeners();
}
protected willUpdate(changedProperties: PropertyValues) {
super.willUpdate(changedProperties);
if (changedProperties.has("_maxColumns")) {
@@ -105,80 +83,67 @@ export const ConditionalListenerMixin = <
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
// After willUpdate so consumers can set `_conditionContext.entity_id`.
if (
changedProperties.has("hass") ||
changedProperties.has("config") ||
changedProperties.has("_config") ||
changedProperties.has("_maxColumns")
) {
this.setupConditionalListeners();
if (changedProperties.has("_maxColumns")) {
this._updateVisibility?.();
}
}
/**
* True if the observed conditions currently pass.
* Uses the server result when known; otherwise a local seed that stays
* unknown (treated as hidden) for anything only core can evaluate.
* Clear conditional listeners
*
* 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.
*/
protected _conditionsVisible(): boolean {
const conditions = this.__conditions;
if (!conditions || conditions.length === 0) {
return true;
}
if (this.__conditionResult !== "unknown") {
return this.__conditionResult === "visible";
}
if (!this.hass) {
return true;
}
return (
evaluateConditionsLocally(
conditions,
this.hass,
this._conditionContext
) === true
);
protected clearConditionalListeners(): void {
this.__listeners.forEach((unsub) => unsub());
this.__listeners = [];
}
/**
* Pass conditions to the evaluator.
* Override to supply a custom list, then call `super.setupConditionalListeners(...)`.
* 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 setupConditionalListeners(
conditions?: VisibilityCondition[]
): void {
// Prefer resolved `_config` (strategy sections) over the raw `config`.
const config = this._config || this.config;
const finalConditions = conditions ?? config?.visibility;
const entityId = this._conditionContext.entity_id;
protected addConditionalListener(unsubscribe: () => void): void {
this.__listeners.push(unsubscribe);
}
this.__conditions = finalConditions;
/**
* 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;
// Fold in the host entity and keep a stable array across hass updates.
if (
finalConditions !== this.__observedSource ||
entityId !== this.__observedEntityId
) {
// Tree content changed; don't keep the previous 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.map((c) => addEntityToCondition(c, entityId))
: finalConditions;
if (!finalConditions || !this.hass) {
return;
}
this.__conditionEvaluator.observe(
this.__observed,
setupConditionListeners(
finalConditions,
this.hass,
(unsub) => this.addConditionalListener(unsub),
(conditionsMet) => {
if (this._updateVisibility) {
this._updateVisibility(conditionsMet);
} else if (this._updateElement && config) {
this._updateElement(config);
}
},
() => this._conditionContext
);
}
-1
View File
@@ -267,7 +267,6 @@ class OnboardingLocation extends LitElement {
? location[1]
: Number(place.lon),
location_editable: place.place_id === highlightedMarker,
clickable: true,
}))
: [];
}
@@ -1,6 +1,12 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume } from "@lit/context";
import { mdiClose, mdiHelpCircleOutline } from "@mdi/js";
import {
mdiAppleKeyboardCommand,
mdiClose,
mdiContentPaste,
mdiHelpCircleOutline,
mdiPlus,
} from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -116,8 +122,8 @@ import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { haStyleScrollbar } from "../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { isMac } from "../../../util/is_mac";
import { showToast } from "../../../util/toast";
import "./add-automation-element/ha-automation-add-element-paste";
import "./add-automation-element/ha-automation-add-from-target";
import "./add-automation-element/ha-automation-add-items";
import "./add-automation-element/ha-automation-add-search";
@@ -718,9 +724,6 @@ class DialogAddAutomationElement
(this._narrow && !!this._selectedGroup),
})}
.manifests=${this._manifests}
.clipboardItem=${this._params!.clipboardItem}
.automationElementType=${automationElementType}
@paste-element=${this._paste}
></ha-automation-add-from-target>`
: html`
<ha-list-base
@@ -730,12 +733,56 @@ class DialogAddAutomationElement
"ha-scrollbar": true,
})}
>
<ha-automation-add-element-paste
.automationElementType=${automationElementType}
.clipboardItem=${this._params!.clipboardItem}
@paste-element=${this._paste}
divider
></ha-automation-add-element-paste>
${
this._params!.clipboardItem
? html`<ha-list-item-button
class="paste"
@click=${this._paste}
>
<div slot="headline" class="label">
${this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.paste`
)}
</div>
<div slot="supporting-text">
${this.hass.localize(
// @ts-ignore
`ui.panel.config.automation.editor.${automationElementType}s.type.${this._params.clipboardItem}.label`
)}
</div>
${
!this._narrow
? html`<span slot="end" class="shortcut">
<span
>${
isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
"ui.panel.config.automation.editor.ctrl"
)
}</span
>
<span>+</span>
<span>V</span>
</span>`
: nothing
}
<ha-svg-icon
slot="start"
.path=${mdiContentPaste}
></ha-svg-icon
><ha-svg-icon
class="plus"
slot="end"
.path=${mdiPlus}
></ha-svg-icon>
</ha-list-item-button>
<wa-divider></wa-divider>`
: nothing
}
${collections.map(
(collection) => html`
${
@@ -2400,16 +2447,15 @@ class DialogAddAutomationElement
border-radius: var(--ha-border-radius-xl);
border: 1px solid var(--ha-color-border-neutral-quiet);
margin: var(--ha-space-3);
overflow: auto;
flex: 0 0 360px;
margin-inline-end: 0;
}
@media (max-width: 870px), (max-height: 500px) {
ha-automation-add-from-target,
.groups {
flex: 1 1 auto;
}
ha-automation-add-from-target,
.groups {
overflow: auto;
/* Fixed-width left column so it does not resize as the right
panel's content width changes between groups. */
flex: 0 0 360px;
margin-inline-end: 0;
}
ha-automation-add-from-target.hidden {
@@ -2480,10 +2526,29 @@ class DialogAddAutomationElement
width: var(--ha-space-6);
}
wa-divider {
--spacing: 0;
}
ha-svg-icon.plus {
color: var(--primary-color);
}
.shortcut {
direction: ltr;
--mdc-icon-size: var(--ha-space-3);
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 2px;
margin-right: var(--ha-space-4);
}
.shortcut span {
font-size: var(--ha-font-size-s);
font-family: var(--ha-font-family-code);
color: var(--ha-color-text-secondary);
}
.section-title-wrapper {
height: 0;
position: relative;
@@ -1,112 +0,0 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume, type ContextType } from "@lit/context";
import { mdiAppleKeyboardCommand, mdiContentPaste, mdiPlus } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-svg-icon";
import "../../../../components/item/ha-list-item-button";
import {
internationalizationContext,
narrowViewportContext,
} from "../../../../data/context";
import { isMac } from "../../../../util/is_mac";
import type { AddAutomationElementDialogParams } from "../show-add-automation-element-dialog";
import { shortcutStyles } from "../styles";
@customElement("ha-automation-add-element-paste")
export class HaAutomationAddElementPaste extends LitElement {
@property({ attribute: "clipboard-item" }) public clipboardItem?;
@property({ attribute: "automation-element-type" })
public automationElementType!: AddAutomationElementDialogParams["type"];
@property({ type: Boolean }) public divider = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
protected _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: narrowViewportContext, subscribe: true })
protected _narrow!: ContextType<typeof narrowViewportContext>;
protected render() {
if (!this.clipboardItem) {
return nothing;
}
return html`<ha-list-item-button class="paste" @click=${this._paste}>
<div slot="headline" class="label">
${this._i18n.localize(
`ui.panel.config.automation.editor.${this.automationElementType}s.paste`
)}
</div>
<div slot="supporting-text">
${this._i18n.localize(
// @ts-ignore
`ui.panel.config.automation.editor.${this.automationElementType}s.type.${this.clipboardItem}.label`
)}
</div>
${
!this._narrow
? html`<span slot="end" class="shortcut">
<span
>${
isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this._i18n.localize(
"ui.panel.config.automation.editor.ctrl"
)
}</span
>
<span>+</span>
<span>V</span>
</span>`
: nothing
}
<ha-svg-icon slot="start" .path=${mdiContentPaste}></ha-svg-icon
><ha-svg-icon class="plus" slot="end" .path=${mdiPlus}></ha-svg-icon>
</ha-list-item-button>
${this.divider ? html`<wa-divider></wa-divider>` : nothing}`;
}
private _paste() {
fireEvent(this, "paste-element");
}
static styles = [
shortcutStyles,
css`
:host {
display: block;
}
ha-list-item-button {
--ha-row-item-padding-block: var(--ha-space-1);
--ha-row-item-padding-inline: var(--ha-space-3);
--ha-row-item-min-height: 40px;
}
wa-divider {
--spacing: 0;
}
ha-svg-icon.plus {
color: var(--primary-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-add-element-paste": HaAutomationAddElementPaste;
}
interface HASSDomEvents {
"paste-element": undefined;
}
}
@@ -65,8 +65,6 @@ import {
import type { HomeAssistant } from "../../../../types";
import { brandsUrl } from "../../../../util/brands-url";
import type { AddAutomationElementListItem } from "../add-automation-element-dialog";
import type { AddAutomationElementDialogParams } from "../show-add-automation-element-dialog";
import "./ha-automation-add-element-paste";
interface Level1Entries {
open: boolean;
@@ -107,11 +105,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
@property({ attribute: false }) public selectedGroup?: string;
@property({ attribute: false }) public clipboardItem?: string;
@property({ attribute: "automation-element-type" })
public automationElementType!: AddAutomationElementDialogParams["type"];
// #endregion properties
// #region context
@@ -200,12 +193,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
this.narrow && this.value
? this._renderNarrow(this._entries, this.value)
: html`
<ha-list-base>
<ha-automation-add-element-paste
.automationElementType=${this.automationElementType}
.clipboardItem=${this.clipboardItem}
></ha-automation-add-element-paste>
</ha-list-base>
${this._renderFloors(this.narrow, this._entries, this.value)}
${this._renderTimeLocation(
this.narrow,
@@ -357,32 +344,28 @@ export default class HaAutomationAddFromTarget extends LitElement {
);
});
return html`${
!narrow || (this._floorAreas.length >= 1 && this._floorAreas[0].id)
? html`<ha-section-title
>${this._i18n.localize(
"ui.panel.config.automation.editor.home"
)}</ha-section-title
>`
: nothing
}
${
emptyFloors
? html`<ha-row-item>
<div slot="headline">
${this._i18n.localize("ui.components.area-picker.no_areas")}
</div>
</ha-row-item>`
: html`${
narrow
? html`<ha-list-base>${floorAreas}</ha-list-base>`
: html`<wa-tree
@wa-selection-change=${this._handleSelectionChange}
@dblclick=${this._handleDoubleClick}
>${floorAreas}</wa-tree
>`
}`
}`;
return html`<ha-section-title
>${this._i18n.localize(
"ui.panel.config.automation.editor.home"
)}</ha-section-title
>
${
emptyFloors
? html`<ha-row-item>
<div slot="headline">
${this._i18n.localize("ui.components.area-picker.no_areas")}
</div>
</ha-row-item>`
: html`${
narrow
? html`<ha-list-base>${floorAreas}</ha-list-base>`
: html`<wa-tree
@wa-selection-change=${this._handleSelectionChange}
@dblclick=${this._handleDoubleClick}
>${floorAreas}</wa-tree
>`
}`
}`;
}
);
@@ -65,6 +65,7 @@ import {
import { fullEntitiesContext } from "../../../../data/context";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { TargetSelector } from "../../../../data/selector";
import { getTargetEntityCount } from "../../../../data/target";
import {
showAlertDialog,
showPromptDialog,
@@ -75,6 +76,7 @@ import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import "../ha-automation-row-behavior";
import "../ha-automation-row-options";
import "../ha-automation-row-threshold";
import { overflowStyles, rowStyles } from "../styles";
import { getDeviceTarget } from "../target/get_device_target";
import { getEntityTarget } from "../target/get_entity_target";
@@ -232,10 +234,10 @@ export default class HaAutomationConditionRow extends LitElement {
)}
${
this._getType(this.condition, this.conditionDescriptions) ===
"platform"
"platform" && targetRequired
? html`<ha-automation-row-behavior
mode="condition"
.config=${this.condition}
.config=${getTargetEntityCount(target) > 1 ? this.condition : undefined}
></ha-automation-row-behavior>`
: nothing
}
@@ -249,6 +251,17 @@ export default class HaAutomationConditionRow extends LitElement {
)
: nothing
}
${
this._getType(this.condition, this.conditionDescriptions) ===
"platform"
? html`<ha-automation-row-threshold
.config=${this.condition}
.description=${
this.conditionDescriptions[this.condition.condition]
}
></ha-automation-row-threshold>`
: nothing
}
${
this._getType(this.condition, this.conditionDescriptions) ===
"platform"
@@ -7,9 +7,8 @@ import { createDurationData } from "../../../../../common/datetime/create_durati
import { durationDataToSeconds } from "../../../../../common/datetime/duration_to_seconds";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../../common/dom/stop_propagation";
import { afterNextRender } from "../../../../../common/util/render-status";
import "../../../../../components/ha-checkbox";
import { getSelectorFallbackValue } from "../../../../../components/ha-form/get-selector-fallback-value";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
import "../../../../../components/ha-svg-icon";
@@ -164,8 +163,7 @@ export class HaPlatformCondition extends LitElement {
}
if (oldValue?.target !== this.condition?.target) {
this._updateTargetEntityCount();
this._setDefaultBehavior();
this._updateResolvedTargetEntityCount(this.condition?.target);
}
}
@@ -478,51 +476,41 @@ export class HaPlatformCondition extends LitElement {
}
}
private _updateTargetEntityCount() {
const target = this.condition?.target;
private _updateResolvedTargetEntityCount(
target: PlatformCondition["target"]
) {
this._resolvedTargetEntityCount = getTargetEntityCount(target);
}
private _setDefaultBehavior() {
// set default behavior after next render to prevent race conditions with the initial render
afterNextRender(() => {
if (!this.isConnected) {
return;
}
const behaviorFieldEntry = Object.entries(
this.description?.fields ?? {}
).find(
([, field]) => field.selector && "automation_behavior" in field.selector
);
const behaviorFieldEntry = Object.entries(
this.description?.fields ?? {}
).find(
([, field]) => field.selector && "automation_behavior" in field.selector
);
if (!behaviorFieldEntry) {
return;
}
if (
!behaviorFieldEntry ||
this._resolvedTargetEntityCount === undefined
) {
return;
}
const [behaviorFieldName, behaviorField] = behaviorFieldEntry;
const [behaviorFieldName, behaviorField] = behaviorFieldEntry;
if (
this.condition?.target &&
this._resolvedTargetEntityCount > 1 &&
this.condition.options?.[behaviorFieldName] === undefined
) {
const behaviorDefault = behaviorField.default;
if (behaviorDefault !== undefined) {
fireEvent(this, "value-changed", {
value: {
...this.condition,
options: {
...this.condition.options,
[behaviorFieldName]: behaviorDefault,
},
if (
target &&
this._resolvedTargetEntityCount > 1 &&
this.condition.options?.[behaviorFieldName] === undefined
) {
const behaviorDefault = behaviorField.default;
if (behaviorDefault !== undefined) {
fireEvent(this, "value-changed", {
value: {
...this.condition,
options: {
...this.condition.options,
[behaviorFieldName]: behaviorDefault,
},
});
}
},
});
}
});
}
}
// Shows a small info icon beside the `for` duration field's label, with a
@@ -1,5 +1,5 @@
import { consume, type ContextType } from "@lit/context";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { html, LitElement, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { internationalizationContext } from "../../../data/context";
import type {
@@ -16,7 +16,7 @@ interface HaAutomationRowBehaviorConfig {
@customElement("ha-automation-row-behavior")
export class HaAutomationRowBehavior extends LitElement {
@property({ attribute: false }) public config!: HaAutomationRowBehaviorConfig;
@property({ attribute: false }) public config?: HaAutomationRowBehaviorConfig;
@property() public mode: "trigger" | "condition" = "trigger";
@@ -40,22 +40,18 @@ export class HaAutomationRowBehavior extends LitElement {
);
}
protected render() {
const label = this._label;
if (!label) {
return nothing;
}
return html`<span class="dot-separator"></span>${label}`;
}
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
// Collapse the host when empty so the parent flex gap is not reserved.
this.toggleAttribute("hidden", !this._label);
}
protected render() {
const label = this._label;
return html`<span class="dot-separator"></span>${label}`;
}
static styles = rowSummaryStyles;
}
@@ -0,0 +1,159 @@
import { consume, type ContextType } from "@lit/context";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { formatNumber } from "../../../common/number/format_number";
import { blankBeforeUnit } from "../../../common/translations/blank_before_unit";
import type {
NumericThresholdValue,
ThresholdValueEntry,
} from "../../../components/ha-selector/ha-selector-numeric-threshold";
import type {
PlatformCondition,
PlatformTrigger,
} from "../../../data/automation";
import type { ConditionDescription } from "../../../data/condition";
import { internationalizationContext } from "../../../data/context";
import type { NumericThresholdSelector } from "../../../data/selector";
import type { TriggerDescription } from "../../../data/trigger";
import { rowSummaryStyles } from "./styles";
@customElement("ha-automation-row-threshold")
export class HaAutomationRowThreshold extends LitElement {
@property({ attribute: false })
public config!: PlatformTrigger | PlatformCondition;
@property({ attribute: false })
public description?: TriggerDescription | ConditionDescription;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
// Collapse the host when empty so the parent flex gap is not reserved.
this.toggleAttribute(
"hidden",
!this._getLabel(
this._getThreshold(this.config, this.description),
this._i18n
)
);
}
protected render() {
const label = this._getLabel(
this._getThreshold(this.config, this.description),
this._i18n
);
if (!label) {
return nothing;
}
return html`<span class="dot-separator"></span>${label}`;
}
private _getThreshold = memoizeOne(
(
config: PlatformTrigger | PlatformCondition,
description: TriggerDescription | ConditionDescription | undefined
) => {
const fields = description?.fields;
if (!fields || !config.options) {
return undefined;
}
const entry = Object.entries(fields).find(
([, field]) => field.selector && "numeric_threshold" in field.selector
);
if (!entry) {
return undefined;
}
const value = config.options[entry[0]] as
NumericThresholdValue | undefined;
if (!value?.type) {
return undefined;
}
const threshold = (entry[1].selector as NumericThresholdSelector)
.numeric_threshold;
const fallbackUnit =
threshold?.unit_of_measurement?.[0] ??
threshold?.number?.unit_of_measurement;
const withUnit = (bound?: ThresholdValueEntry) =>
bound
? {
...bound,
unit_of_measurement: bound.unit_of_measurement ?? fallbackUnit,
}
: bound;
return {
...value,
value: withUnit(value.value),
value_min: withUnit(value.value_min),
value_max: withUnit(value.value_max),
};
}
);
private _getLabel = memoizeOne(
(threshold: NumericThresholdValue | undefined, i18n: typeof this._i18n) => {
if (
!threshold ||
!threshold?.type ||
threshold.type === "any" ||
threshold.value?.active_choice === "entity" ||
threshold.value_min?.active_choice === "entity" ||
threshold.value_max?.active_choice === "entity"
) {
return undefined;
}
if (threshold.type === "between" || threshold.type === "outside") {
const min = threshold.value_min?.number;
const max = threshold.value_max?.number;
if (min === undefined || max === undefined) {
return undefined;
}
const separateUnits =
threshold.value_min?.unit_of_measurement !==
threshold.value_max?.unit_of_measurement;
const range = `${formatNumber(min, this._i18n.locale)}${separateUnits ? this._unit(threshold.value_min?.unit_of_measurement) : ""}-${formatNumber(max, this._i18n.locale)}${this._unit(threshold.value_max?.unit_of_measurement)}`;
return i18n.localize(
`ui.components.selectors.numeric_threshold.row_label.${threshold.type}`,
{ value: range }
);
}
if (isNaN(Number(threshold.value?.number))) {
return undefined;
}
return i18n.localize(
`ui.components.selectors.numeric_threshold.row_label.${threshold.type}`,
{
value: `${formatNumber(threshold.value!.number!, this._i18n.locale)}${this._unit(threshold.value!.unit_of_measurement ?? "")}`,
}
);
}
);
private _unit(unit?: string): string {
if (!unit) {
return "";
}
return `${blankBeforeUnit(unit, this._i18n.locale)}${unit}`;
}
static styles = rowSummaryStyles;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-row-threshold": HaAutomationRowThreshold;
}
}
-17
View File
@@ -313,20 +313,3 @@ export const overflowStyles = css`
}
}
`;
export const shortcutStyles = css`
.shortcut {
direction: ltr;
--mdc-icon-size: var(--ha-space-3);
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 2px;
margin-right: var(--ha-space-4);
}
.shortcut span {
font-size: var(--ha-font-size-s);
font-family: var(--ha-font-family-code);
color: var(--ha-color-text-secondary);
}
`;
@@ -62,6 +62,7 @@ import { validateConfig } from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { TargetSelector } from "../../../../data/selector";
import { getTargetEntityCount } from "../../../../data/target";
import type { TriggerDescriptions } from "../../../../data/trigger";
import { isTriggerList } from "../../../../data/trigger";
import {
@@ -74,6 +75,7 @@ import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import "../ha-automation-row-behavior";
import "../ha-automation-row-options";
import "../ha-automation-row-threshold";
import { overflowStyles, rowStyles } from "../styles";
import { getDeviceTarget } from "../target/get_device_target";
import { getEntityTarget } from "../target/get_entity_target";
@@ -256,9 +258,9 @@ export default class HaAutomationTriggerRow extends LitElement {
describeTrigger(this.trigger, this.hass, this._entityReg)
)}
${
type === "platform"
type === "platform" && targetRequired
? html`<ha-automation-row-behavior
.config=${this.trigger}
.config=${getTargetEntityCount(target) > 1 ? this.trigger : undefined}
></ha-automation-row-behavior>`
: nothing
}
@@ -272,6 +274,18 @@ export default class HaAutomationTriggerRow extends LitElement {
)
: nothing
}
${
type === "platform"
? html`<ha-automation-row-threshold
.config=${this.trigger}
.description=${
this.triggerDescriptions[
(this.trigger as PlatformTrigger).trigger
]
}
></ha-automation-row-threshold>`
: nothing
}
${
type === "platform"
? html`<ha-automation-row-options
@@ -4,9 +4,8 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { afterNextRender } from "../../../../../common/util/render-status";
import "../../../../../components/ha-checkbox";
import { getSelectorFallbackValue } from "../../../../../components/ha-form/get-selector-fallback-value";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
import type { PlatformTrigger } from "../../../../../data/automation";
@@ -156,8 +155,7 @@ export class HaPlatformTrigger extends LitElement {
}
if (oldValue?.target !== this.trigger?.target) {
this._updateTargetEntityCount();
this._setDefaultBehavior();
this._updateResolvedTargetEntityCount(this.trigger?.target);
}
}
@@ -468,51 +466,39 @@ export class HaPlatformTrigger extends LitElement {
}
}
private _updateTargetEntityCount() {
const target = this.trigger?.target;
private _updateResolvedTargetEntityCount(target: PlatformTrigger["target"]) {
this._resolvedTargetEntityCount = getTargetEntityCount(target);
}
private _setDefaultBehavior() {
// set default behavior after next render to prevent race conditions with the initial render
afterNextRender(() => {
if (!this.isConnected) {
return;
}
const behaviorFieldEntry = Object.entries(
this.description?.fields ?? {}
).find(
([, field]) => field.selector && "automation_behavior" in field.selector
);
const behaviorFieldEntry = Object.entries(
this.description?.fields ?? {}
).find(
([, field]) => field.selector && "automation_behavior" in field.selector
);
if (!behaviorFieldEntry) {
return;
}
if (
!behaviorFieldEntry ||
this._resolvedTargetEntityCount === undefined
) {
return;
}
const [behaviorFieldName, behaviorField] = behaviorFieldEntry;
const [behaviorFieldName, behaviorField] = behaviorFieldEntry;
if (
this.trigger?.target &&
this._resolvedTargetEntityCount > 1 &&
this.trigger.options?.[behaviorFieldName] === undefined
) {
const behaviorDefault = behaviorField.default;
if (behaviorDefault !== undefined) {
fireEvent(this, "value-changed", {
value: {
...this.trigger,
options: {
...this.trigger.options,
[behaviorFieldName]: behaviorDefault,
},
if (
target &&
this._resolvedTargetEntityCount > 1 &&
this.trigger.options?.[behaviorFieldName] === undefined
) {
const behaviorDefault = behaviorField.default;
if (behaviorDefault !== undefined) {
fireEvent(this, "value-changed", {
value: {
...this.trigger,
options: {
...this.trigger.options,
[behaviorFieldName]: behaviorDefault,
},
});
}
},
});
}
});
}
}
static styles = css`
-9
View File
@@ -33,7 +33,6 @@ import {
mdiStarFourPoints,
mdiTextBoxOutline,
mdiTools,
mdiTransitConnectionVariant,
mdiUpdate,
mdiViewDashboard,
mdiZigbee,
@@ -189,14 +188,6 @@ export const configSections: Record<string, PageNavigation[]> = {
translationKey: "serial",
adminOnly: true,
},
{
path: "/config/modbus",
iconPath: mdiTransitConnectionVariant,
iconColor: "#00897B",
component: "modbus",
translationKey: "modbus",
adminOnly: true,
},
{
path: "/config/infrared",
iconPath: mdiRemote,
-6
View File
@@ -232,12 +232,6 @@ class HaPanelConfig extends HassRouterPage {
import("./integrations/integration-panels/serial/serial-config-dashboard"),
waitForReady: true,
},
modbus: {
tag: "modbus-config-dashboard",
load: () =>
import("./integrations/integration-panels/modbus/modbus-config-dashboard"),
waitForReady: true,
},
dhcp: {
tag: "dhcp-config-panel",
load: () =>
@@ -1,521 +0,0 @@
import {
mdiCableData,
mdiLan,
mdiPuzzle,
mdiRefresh,
mdiTransitConnectionVariant,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
import { caseInsensitiveStringCompare } from "../../../../../common/string/compare";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-svg-icon";
import type { ConfigEntry } from "../../../../../data/config_entries";
import { getConfigEntries } from "../../../../../data/config_entries";
import { domainToName } from "../../../../../data/integration";
import type { ModbusConnection } from "../../../../../data/modbus";
import {
listModbusConnections,
modbusEndpointTarget,
modbusSerialDevice,
modbusUnitCount,
} from "../../../../../data/modbus";
import "../../../../../layouts/hass-subpage";
import { panelIsReady } from "../../../../../layouts/panel-ready";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import { brandsUrl } from "../../../../../util/brands-url";
const TRANSPORTS = ["tcp", "udp", "serial"] as const;
type ModbusTransport = (typeof TRANSPORTS)[number];
const isKnownTransport = (transport: string): transport is ModbusTransport =>
(TRANSPORTS as readonly string[]).includes(transport);
interface ConnectionHolder {
entryId: string;
entry?: ConfigEntry;
units: number[];
}
interface ConnectionListItem {
icon: string;
primary: string;
transport: string;
state: string;
serialDevice?: string;
holders: ConnectionHolder[];
connection: ModbusConnection;
}
@customElement("modbus-config-dashboard")
export class ModbusConfigDashboard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public route!: Route;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@state() private _connections?: ModbusConnection[];
@state() private _entries?: Record<string, ConfigEntry>;
@state() private _error?: string;
protected async firstUpdated(): Promise<void> {
await this._fetchConnections();
await panelIsReady(this);
}
private async _fetchConnections(): Promise<void> {
try {
const [connections, entries] = await Promise.all([
listModbusConnections(this.hass),
getConfigEntries(this.hass),
]);
this._connections = connections;
this._entries = Object.fromEntries(
entries.map((entry) => [entry.entry_id, entry])
);
this._error = undefined;
} catch (err: any) {
this._error = err.message;
}
}
private _transportName(transport: string): string {
return isKnownTransport(transport)
? this.hass.localize(`ui.panel.config.modbus.transport.${transport}`)
: transport;
}
// A serial port is open or closed. Opening one says nothing about whether a
// device answers on the wire, which a network connection's handshake does.
private _stateName(connected: boolean, serial: boolean): string {
if (serial) {
return this.hass.localize(
`ui.panel.config.modbus.state_${connected ? "open" : "closed"}`
);
}
return this.hass.localize(
`ui.panel.config.modbus.state_${connected ? "connected" : "not_connected"}`
);
}
private _connectionListItem(
connection: ModbusConnection,
entries: Record<string, ConfigEntry>
): ConnectionListItem {
const [transport] = connection.endpoint;
const serialDevice = modbusSerialDevice(connection.endpoint);
return {
icon: serialDevice ? mdiCableData : mdiLan,
primary: modbusEndpointTarget(connection.endpoint),
transport: this._transportName(transport),
state: this._stateName(connection.connected, serialDevice !== undefined),
serialDevice,
holders: Object.entries(connection.units).map(([entryId, units]) => ({
entryId,
entry: entries[entryId],
units,
})),
connection,
};
}
private _sortedConnections = memoizeOne(
(
connections: ModbusConnection[],
entries: Record<string, ConfigEntry>,
_localize: HomeAssistant["localize"],
language: string
): ConnectionListItem[] =>
connections
.map((connection) => this._connectionListItem(connection, entries))
.sort((a, b) =>
caseInsensitiveStringCompare(a.primary, b.primary, language)
)
);
private _renderUnits(holder: ConnectionHolder): TemplateResult {
return html`
<div slot="supporting-text">
${this.hass.localize("ui.panel.config.modbus.units", {
count: holder.units.length,
ids: holder.units.join(", "),
})}
</div>
`;
}
private _renderHolder(holder: ConnectionHolder): TemplateResult {
// An entry removed while the connection was listed has nowhere to link to
if (!holder.entry) {
return html`
<ha-md-list-item class="holder">
<ha-svg-icon slot="start" .path=${mdiPuzzle}></ha-svg-icon>
<div slot="headline">${holder.entryId}</div>
${this._renderUnits(holder)}
</ha-md-list-item>
`;
}
const { domain, title } = holder.entry;
return html`
<ha-md-list-item
type="link"
class="holder"
href=${`/config/integrations/integration/${domain}#config_entry=${holder.entryId}`}
>
<img
slot="start"
.src=${brandsUrl(
{
domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${domain}
/>
<div slot="headline">
${title || domainToName(this.hass.localize, domain)}
</div>
${this._renderUnits(holder)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
`;
}
private _renderSerialPortLink(): TemplateResult {
return html`
<ha-md-list-item type="link" class="holder" href="/config/serial">
<ha-svg-icon slot="start" .path=${mdiCableData}></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.panel.config.modbus.view_serial_port")}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
`;
}
// A closed link is not a fault: the library opens one when an integration
// next polls, and a device may drop an idle one in the meantime
private _renderState(item: ConnectionListItem): TemplateResult {
return html`
<div slot="end" class="state">
<span
class="dot ${item.connection.connected ? "online" : "offline"}"
></span>
${item.state}
</div>
`;
}
private _renderConnectionItem(item: ConnectionListItem): TemplateResult {
return html`
<ha-md-list-item class="connection">
<ha-svg-icon slot="start" .path=${item.icon}></ha-svg-icon>
<div slot="headline">${item.primary}</div>
<div slot="supporting-text">${item.transport}</div>
${this._renderState(item)}
</ha-md-list-item>
${item.holders.map((holder) => this._renderHolder(holder))}
${
item.serialDevice && isComponentLoaded(this.hass.config, "usb")
? this._renderSerialPortLink()
: nothing
}
`;
}
private _renderConnectionsCard(items: ConnectionListItem[]): TemplateResult {
return html`
<ha-card class="connections">
<div class="card-header">
${this.hass.localize("ui.panel.config.modbus.connections")}
</div>
<div class="card-content">
<div class="description">
${this.hass.localize(
"ui.panel.config.modbus.connections_description"
)}
</div>
<ha-md-list>
${items.map((item) => this._renderConnectionItem(item))}
</ha-md-list>
</div>
</ha-card>
`;
}
// No health verdict: the summary is a count, since a closed link is normal
private _renderStatusCard(items: ConnectionListItem[]): TemplateResult {
return html`
<ha-card class="status">
<div class="card-content">
<div class="heading">
<div class="icon">
<ha-svg-icon .path=${mdiTransitConnectionVariant}></ha-svg-icon>
</div>
<div class="details">
${this.hass.localize("ui.panel.config.modbus.status_summary", {
units: items.reduce(
(total, item) => total + modbusUnitCount(item.connection),
0
),
total: items.length,
})}
</div>
</div>
</div>
</ha-card>
`;
}
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.modbus.title")}
back-path="/config/connectivity"
>
<ha-icon-button
slot="toolbar-icon"
.label=${this.hass.localize("ui.common.refresh")}
.path=${mdiRefresh}
@click=${this._handleRefresh}
></ha-icon-button>
<div class="container">${this._renderContent()}</div>
</hass-subpage>
`;
}
private _renderContent(): TemplateResult {
if (this._error !== undefined) {
return html`
<ha-alert
alert-type="error"
.title=${this.hass.localize("ui.panel.config.modbus.loading_error")}
>
${this._error}
</ha-alert>
`;
}
if (!this._connections || !this._entries) {
return html`
<div class="loading">
<ha-spinner></ha-spinner>
</div>
`;
}
const items = this._sortedConnections(
this._connections,
this._entries,
this.hass.localize,
this.hass.locale.language
);
if (!items.length) {
return html`
<ha-card>
<div class="card-content">
<div class="empty">
${this.hass.localize("ui.panel.config.modbus.no_connections")}
<br />
<small>
${this.hass.localize(
"ui.panel.config.modbus.no_connections_description"
)}
</small>
</div>
</div>
</ha-card>
`;
}
return html`
${this._renderStatusCard(items)} ${this._renderConnectionsCard(items)}
`;
}
private _handleRefresh(): void {
this._fetchConnections();
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.container {
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
}
ha-card,
ha-alert {
display: block;
margin: 0px auto var(--ha-space-4);
max-width: 600px;
}
ha-card:first-child {
margin-top: var(--ha-space-6);
}
.loading {
display: flex;
justify-content: center;
padding: var(--ha-space-12) 0;
}
ha-card.connections {
overflow: hidden;
}
ha-card.connections .card-content {
padding: 0;
}
ha-card.connections .card-header {
padding-bottom: var(--ha-space-2);
}
.status div.heading {
display: flex;
align-items: center;
column-gap: var(--ha-space-4);
}
.status div.heading .icon {
position: relative;
border-radius: var(--ha-border-radius-2xl);
width: var(--ha-space-10);
height: var(--ha-space-10);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
--icon-color: var(--primary-color);
}
.status div.heading .icon::before {
display: block;
content: "";
position: absolute;
inset: 0;
background-color: var(--icon-color);
opacity: 0.2;
}
.status div.heading .icon ha-svg-icon {
color: var(--icon-color);
width: var(--ha-space-6);
height: var(--ha-space-6);
}
.status div.heading .details {
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
color: var(--primary-text-color);
}
.status small {
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
letter-spacing: 0.25px;
color: var(--secondary-text-color);
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-list-item-top-space: var(--ha-space-2);
--md-list-item-bottom-space: var(--ha-space-2);
--md-list-item-one-line-container-height: 0;
--md-list-item-two-line-container-height: 0;
--md-list-item-three-line-container-height: 0;
}
ha-md-list-item.connection:not(:first-child) {
border-top: 1px solid var(--divider-color);
margin-top: var(--ha-space-2);
--md-list-item-top-space: var(--ha-space-4);
}
.state {
display: flex;
align-items: center;
column-gap: var(--ha-space-2);
font-size: var(--ha-font-size-m);
color: var(--secondary-text-color);
}
.state .dot {
width: var(--ha-space-2);
height: var(--ha-space-2);
border-radius: var(--ha-border-radius-circle);
background-color: var(--disabled-text-color);
}
.state .dot.online {
background-color: var(--success-color);
}
ha-md-list-item.holder {
--md-list-item-leading-space: var(--ha-space-14);
}
ha-md-list-item.holder img[slot="start"] {
width: 24px;
height: 24px;
}
.description {
padding: 0 var(--ha-space-4) var(--ha-space-2);
color: var(--secondary-text-color);
}
.empty {
padding: var(--ha-space-4);
color: var(--secondary-text-color);
}
.empty small {
color: var(--secondary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"modbus-config-dashboard": ModbusConfigDashboard;
}
}
@@ -8,7 +8,6 @@ import {
mdiPowerPlugOff,
mdiPuzzle,
mdiRefresh,
mdiTransitConnectionVariant,
mdiUsb,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
@@ -30,10 +29,6 @@ import {
domainToName,
getConfigPanelPath,
} from "../../../../../data/integration";
import {
listModbusConnections,
modbusSerialDevice,
} from "../../../../../data/modbus";
import type {
SerialPort,
SerialPortConsumer,
@@ -107,8 +102,6 @@ export class SerialConfigDashboard extends LitElement {
@state() private _ports?: SerialPortUsage[];
@state() private _modbusDevices?: Set<string>;
@state() private _error?: string;
protected async firstUpdated(): Promise<void> {
@@ -118,36 +111,13 @@ export class SerialConfigDashboard extends LitElement {
private async _fetchPorts(): Promise<void> {
try {
const [ports, modbusConnections] = await Promise.all([
listSerialPortsWithUsage(this.hass),
// Modbus only annotates the ports; failing to reach it is not an error
isComponentLoaded(this.hass.config, "modbus")
? listModbusConnections(this.hass).catch(() => [])
: [],
]);
this._ports = ports;
this._modbusDevices = new Set(
modbusConnections
.map((connection) => modbusSerialDevice(connection.endpoint))
.filter((device) => device !== undefined)
);
this._ports = await listSerialPortsWithUsage(this.hass);
this._error = undefined;
} catch (err: any) {
this._error = err.message;
}
}
// Modbus keeps the device path it was configured with verbatim, which may be
// an alias of the scanned path, so both are matched
private _usedByModbus(port: SerialPort): boolean {
return (
this._modbusDevices !== undefined &&
(this._modbusDevices.has(port.device) ||
(port.resolved_device !== null &&
this._modbusDevices.has(port.resolved_device)))
);
}
private _portListItem(
port: SerialPortUsage,
localize: HomeAssistant["localize"]
@@ -338,21 +308,6 @@ export class SerialConfigDashboard extends LitElement {
`;
}
private _renderModbusLink(): TemplateResult {
return html`
<ha-md-list-item type="link" class="consumer" href="/config/modbus">
<ha-svg-icon
slot="start"
.path=${mdiTransitConnectionVariant}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.panel.config.serial.used_by_modbus")}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
`;
}
private _continueFlow(ev: Event): void {
showConfigFlowDialog(this, {
continueFlowId: (ev.currentTarget as any).flowId,
@@ -417,7 +372,6 @@ export class SerialConfigDashboard extends LitElement {
}
</ha-md-list-item>
${item.consumers.map((consumer) => this._renderConsumer(consumer))}
${this._usedByModbus(item.port) ? this._renderModbusLink() : nothing}
${item.discoveryFlows.map((flow) => this._renderDiscoveryFlow(flow))}
`;
}
+17 -1
View File
@@ -61,6 +61,8 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
@state() private _stateItems?: HassEntity[];
@state() private _activeEntry = "";
@state() private _canEditCore = false;
@query("ha-locations-editor") private _map?: HaLocationsEditor;
@@ -149,6 +151,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
.hasMeta=${!this.narrow}
@request-selected=${this._itemClicked}
.value=${entry.id}
?selected=${this._activeEntry === entry.id}
>
<ha-icon .icon=${entry.icon} slot="graphic"></ha-icon>
${entry.name}
@@ -182,6 +185,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
.value=${stateObject.entity_id}
@request-selected=${this._stateItemClicked}
?selected=${this._activeEntry === stateObject.entity_id}
.noEdit=${
stateObject.entity_id !== "zone.home" ||
!this._canEditCore
@@ -269,6 +273,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
)}
@location-updated=${this._locationUpdated}
@radius-updated=${this._radiusUpdated}
@marker-clicked=${this._markerClicked}
></ha-locations-editor>
<div class="overflow">${listBox}</div>
</div>
@@ -360,6 +365,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
private async _locationUpdated(ev: CustomEvent) {
this._activeEntry = ev.detail.id;
if (ev.detail.id === "zone.home" && this._canEditCore) {
await saveCoreConfig(this.hass, {
latitude: ev.detail.location[0],
@@ -378,6 +384,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
private async _radiusUpdated(ev: CustomEvent) {
this._activeEntry = ev.detail.id;
if (ev.detail.id === "zone.home" && this._canEditCore) {
await saveCoreConfig(this.hass, {
radius: Math.round(ev.detail.radius),
@@ -393,6 +400,10 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
});
}
private _markerClicked(ev: CustomEvent) {
this._activeEntry = ev.detail.id;
}
private _createZone() {
this._openDialog();
}
@@ -406,7 +417,9 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
this._openEditEntry(ev);
return;
}
this._zoomZone((ev.currentTarget! as any).value);
const entryId: string = (ev.currentTarget! as any).value;
this._zoomZone(entryId);
this._activeEntry = entryId;
}
private _stateItemClicked(ev: CustomEvent) {
@@ -422,6 +435,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
this._zoomZone(entryId);
this._activeEntry = entryId;
}
private async _zoomZone(id: string) {
@@ -464,6 +478,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
if (this.narrow) {
return;
}
this._activeEntry = created.id;
await this.updateComplete;
await this._map?.updateComplete;
this._map?.fitMarker(created.id);
@@ -490,6 +505,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
if (this.narrow || !fitMap) {
return;
}
this._activeEntry = entry.id;
await this.updateComplete;
await this._map?.updateComplete;
this._map?.fitMarker(entry.id);
+9 -1
View File
@@ -7,6 +7,7 @@ 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";
@@ -164,7 +165,14 @@ export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
return;
}
const visible = conditionsMet ?? this._conditionsVisible();
const visible =
conditionsMet ??
(!this.config?.visibility ||
checkConditionsMet(
this.config.visibility,
this.hass,
this._conditionContext
));
this._setElementVisibility(visible);
}
@@ -348,7 +348,6 @@ class HuiEnergySankeyCard
.hass=${this.hass}
.data=${{ nodes, links }}
.vertical=${vertical}
.showValues=${this._config.show_values === true}
.valueFormatter=${this._valueFormatter}
@node-click=${this._handleNodeClick}
></ha-sankey-chart>`
@@ -347,7 +347,6 @@ class HuiPowerSankeyCard
.hass=${this.hass}
.data=${{ nodes, links }}
.vertical=${vertical}
.showValues=${this._config.show_values === true}
.valueFormatter=${this._valueFormatter}
@node-click=${this._handleNodeClick}
></ha-sankey-chart>`
+9 -1
View File
@@ -9,6 +9,7 @@ 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";
@@ -261,7 +262,14 @@ export class HuiCard extends ConditionalListenerMixin<LovelaceCardConfig>(
return;
}
const visible = conditionsMet ?? this._conditionsVisible();
const visible =
conditionsMet ??
(!this.config?.visibility ||
checkConditionsMet(
this.config.visibility,
this.hass,
this._conditionContext
));
this._setElementVisibility(visible);
}
@@ -172,11 +172,9 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
: nothing
}
${
this._config.heading && style === "subtitle"
? html`<h3 class="heading">${this._config.heading}</h3>`
: this._config.heading
? html`<h2 class="heading">${this._config.heading}</h2>`
: nothing
this._config.heading
? html`<p>${this._config.heading}</p>`
: nothing
}
${actionable ? html`<ha-icon-next></ha-icon-next>` : nothing}
</div>
@@ -277,12 +275,9 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
display: flex;
flex: none;
}
.content .heading {
.content p {
margin: 0;
font-style: normal;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+2 -2
View File
@@ -4,6 +4,7 @@ import {
mdiImageFilterCenterFocus,
} from "@mdi/js";
import type { HassEntities } from "home-assistant-js-websocket";
import type { LatLngTuple } from "leaflet";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -28,7 +29,6 @@ import type {
HaMapPaths,
MapCardMarkerLabelMode,
} from "../../../components/map/ha-map";
import type { MapLatLng } from "../../../common/map/map-engine";
import type { HistoryStates } from "../../../data/history";
import { subscribeHistoryStatesTimeWindow } from "../../../data/history";
import type { HomeAssistant } from "../../../types";
@@ -528,7 +528,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
continue;
}
const p = {} as HaMapPathPoint;
p.point = [latitude, longitude] as MapLatLng;
p.point = [latitude, longitude] as LatLngTuple;
p.timestamp = new Date(entityState.lu * 1000);
points.push(p);
}
+2 -12
View File
@@ -23,10 +23,7 @@ import type {
LovelaceCardFeaturePosition,
} from "../card-features/types";
import type { LegacyStateFilter } from "../common/evaluate-filter";
import type {
Condition,
VisibilityCondition,
} from "../common/validate-condition";
import type { Condition, LegacyCondition } from "../common/validate-condition";
import type { HuiImage } from "../components/hui-image";
import type { LogbookNameDetail } from "../../logbook/logbook-entry-model";
import type { TimestampRenderingFormat } from "../components/types";
@@ -62,7 +59,7 @@ export interface CalendarCardConfig extends LovelaceCardConfig {
export interface ConditionalCardConfig extends LovelaceCardConfig {
card: LovelaceCardConfig;
conditions: VisibilityCondition[];
conditions: (Condition | LegacyCondition)[];
}
export interface EmptyStateButtonConfig {
@@ -181,7 +178,6 @@ export interface EnergyCardSankeyConfig extends EnergyCardConfig {
group_by_floor?: boolean;
group_by_area?: boolean;
max_devices?: number;
show_values?: boolean;
}
export interface EnergyDateSelectorCardConfig extends EnergyCardBaseConfig {
@@ -274,12 +270,6 @@ export interface WaterFlowSankeyCardConfig extends EnergyCardSankeyConfig {
type: "water-flow-sankey";
}
export type SankeyCardConfig =
| EnergySankeyCardConfig
| PowerSankeyCardConfig
| WaterSankeyCardConfig
| WaterFlowSankeyCardConfig;
export interface EntityFilterCardConfig extends LovelaceCardConfig {
type: "entity-filter";
entities: (EntityFilterEntityConfig | string)[];
@@ -306,10 +306,8 @@ class HuiWaterFlowSankeyCard
${
hasData
? html`<ha-sankey-chart
.hass=${this.hass}
.data=${{ nodes, links }}
.vertical=${vertical}
.showValues=${this._config.show_values === true}
.valueFormatter=${this._valueFormatter}
@node-click=${this._handleNodeClick}
></ha-sankey-chart>`
@@ -291,7 +291,6 @@ class HuiWaterSankeyCard
.hass=${this.hass}
.data=${{ nodes, links }}
.vertical=${vertical}
.showValues=${this._config.show_values === true}
.valueFormatter=${this._valueFormatter}
@node-click=${this._handleNodeClick}
></ha-sankey-chart>`
+2 -12
View File
@@ -2,23 +2,17 @@ import {
mdiAccount,
mdiAmpersand,
mdiCalendarClock,
mdiCodeBraces,
mdiDevices,
mdiGateOr,
mdiMapMarker,
mdiMapMarkerRadius,
mdiNotEqualVariant,
mdiNumeric,
mdiResponsive,
mdiStateMachine,
mdiViewColumnOutline,
mdiWeatherSunny,
} from "@mdi/js";
import type { Condition } from "./validate-condition";
// 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> = {
export const ICON_CONDITION: Record<Condition["condition"], string> = {
view_columns: mdiViewColumnOutline,
location: mdiMapMarker,
numeric_state: mdiNumeric,
@@ -29,8 +23,4 @@ export const ICON_CONDITION: Record<string, string> = {
and: mdiAmpersand,
not: mdiNotEqualVariant,
or: mdiGateOr,
template: mdiCodeBraces,
sun: mdiWeatherSunny,
zone: mdiMapMarkerRadius,
device: mdiDevices,
};
@@ -8,15 +8,6 @@ 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";
@@ -108,46 +99,6 @@ export interface NotCondition extends BaseCondition {
conditions?: Condition[];
}
/**
* Dashboard visibility: client-only lovelace types (`screen`, `user`,
* `view_columns`, `location`, `time`) plus core automation conditions.
* Lovelace `state`/`numeric_state` (`entity`) and core (`entity_id`) both
* exist; existing dashboards keep the old shape until edited.
* See `common/condition/translate.ts`.
*/
export type VisibilityCondition =
| ScreenCondition
| UserCondition
| ViewColumnsCondition
| LocationCondition
| TimeCondition
| StateCondition
| NumericStateCondition
| LegacyCondition
| CoreVisibilityCondition
| VisibilityLogicalCondition;
/**
* Core conditions used for dashboard visibility. Omits client `time` and
* `trigger`. `PlatformCondition` also covers core `state` / `numeric_state`.
*/
export type CoreVisibilityCondition =
| CoreStateCondition
| CoreNumericStateCondition
| SunCondition
| ZoneCondition
| TemplateCondition
| DeviceCondition
| CorePlatformCondition;
/**
* Mixed `and` / `or` / `not`. `conditions` may be one item or a list.
*/
export interface VisibilityLogicalCondition extends BaseCondition {
condition: "and" | "or" | "not";
conditions?: VisibilityCondition | VisibilityCondition[];
}
function getValueFromEntityId(
hass: HomeAssistant,
value: string
@@ -163,13 +114,7 @@ function checkStateCondition(
hass: HomeAssistant,
context: ConditionContext
) {
// Prefer core `entity_id` over lovelace `entity` / the host entity.
const entityId =
("entity_id" in condition
? (condition as { entity_id?: string }).entity_id
: undefined) ||
condition.entity ||
context.entity_id;
const entityId = condition.entity || context.entity_id;
const stateObj = entityId ? hass.states[entityId] : undefined;
const attribute = "attribute" in condition ? condition.attribute : undefined;
let state: string;
@@ -212,13 +157,7 @@ function checkStateNumericCondition(
hass: HomeAssistant,
context: ConditionContext
) {
// Prefer core `entity_id` over lovelace `entity` / the host entity.
const entityId =
("entity_id" in condition
? (condition as { entity_id?: string }).entity_id
: undefined) ||
condition.entity ||
context.entity_id;
const entityId = condition.entity || context.entity_id;
const stateObj = entityId ? hass.states[entityId] : undefined;
const state = condition.attribute
? stateObj?.attributes[condition.attribute]
@@ -476,10 +415,9 @@ function validateNumericStateCondition(condition: NumericStateCondition) {
* @returns true if conditions are validated
*/
export function validateConditionalConfig(
conditions: VisibilityCondition[]
conditions: (Condition | LegacyCondition)[]
): boolean {
return conditions.every((visibilityCondition) => {
const c = visibilityCondition as Condition | LegacyCondition;
return conditions.every((c) => {
if ("condition" in c) {
switch (c.condition) {
case "view_columns":
@@ -494,8 +432,6 @@ export function validateConditionalConfig(
return validateLocationCondition(c);
case "numeric_state":
return validateNumericStateCondition(c);
case "state":
return validateStateCondition(c);
case "and":
return validateAndCondition(c);
case "not":
@@ -503,8 +439,7 @@ export function validateConditionalConfig(
case "or":
return validateOrCondition(c);
default:
// template / sun / zone / device / integrations: core validates these.
return true;
return validateStateCondition(c);
}
}
return validateStateCondition(c);
@@ -517,29 +452,26 @@ export function validateConditionalConfig(
* @param entityId base the condition on that entity
* @returns a new condition with entity id
*/
export function addEntityToCondition<T extends VisibilityCondition>(
condition: T,
export function addEntityToCondition(
condition: Condition,
entityId: string
): T {
): Condition {
if ("conditions" in condition && condition.conditions) {
return {
...condition,
conditions: ensureArray(
condition.conditions as VisibilityCondition | VisibilityCondition[]
).map((c) => addEntityToCondition(c, entityId)),
} as T;
conditions: condition.conditions.map((c) =>
addEntityToCondition(c, entityId)
),
};
}
// Entity-less lovelace state/numeric_state (including `{ entity, state }`)
// target the host entity. Don't stamp `entity` onto a core `entity_id` leaf.
const type = (condition as { condition?: string }).condition ?? "state";
if (
(type === "state" || type === "numeric_state") &&
!("entity_id" in condition)
condition.condition === "state" ||
condition.condition === "numeric_state"
) {
return {
entity: entityId,
...condition,
entity: (condition as { entity?: string }).entity || entityId,
};
}
return condition;
@@ -5,7 +5,11 @@ 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 { validateConditionalConfig } from "../common/validate-condition";
import type { Condition } from "../common/validate-condition";
import {
checkConditionsMet,
validateConditionalConfig,
} from "../common/validate-condition";
import type { ConditionalRowConfig, LovelaceRow } from "../entity-rows/types";
declare global {
@@ -22,10 +26,6 @@ export class HuiConditionalBase extends ConditionalListenerMixin<
@property({ type: Boolean }) public preview = false;
// Stay mounted while hidden so a server condition can flip back to visible.
// If hui-card unmounts us, only client conditions can recover from the seed.
public connectedWhileHidden = true;
@state() protected _config?: ConditionalCardConfig | ConditionalRowConfig;
protected _element?: HuiCard | LovelaceRow;
@@ -66,11 +66,17 @@ export class HuiConditionalBase extends ConditionalListenerMixin<
}
protected setupConditionalListeners() {
if (!this._config) {
if (!this._config || !this.hass) {
return;
}
super.setupConditionalListeners(this._config.conditions);
// 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);
}
protected update(changed: PropertyValues): void {
@@ -82,6 +88,7 @@ export class HuiConditionalBase extends ConditionalListenerMixin<
changed.has("hass") ||
changed.has("preview")
) {
this.clearConditionalListeners();
this.setupConditionalListeners();
this._updateVisibility();
}
@@ -94,7 +101,13 @@ export class HuiConditionalBase extends ConditionalListenerMixin<
this._element.preview = this.preview;
const conditionMet = conditionsMet ?? this._conditionsVisible();
const conditionMet =
conditionsMet ??
checkConditionsMet(
this._config.conditions,
this.hass,
this._conditionContext
);
this.setVisibility(conditionMet);
}
+2 -2
View File
@@ -1,11 +1,11 @@
import { enums, object, optional, union } from "superstruct";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { VisibilityCondition } from "../common/validate-condition";
import type { Condition } from "../common/validate-condition";
import type { LovelaceElementConfig } from "../elements/types";
export interface ConditionalBaseConfig extends LovelaceCardConfig {
card: LovelaceCardConfig | LovelaceElementConfig;
conditions: VisibilityCondition[];
conditions: Condition[];
}
export const TIMESTAMP_RENDERING_FORMATS = [
@@ -5,7 +5,7 @@ import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { HomeAssistant } from "../../../../types";
import type { VisibilityCondition } from "../../common/validate-condition";
import type { Condition } from "../../common/validate-condition";
import { conditionsEntityContext } from "../conditions/context";
import "../conditions/ha-card-conditions-editor";
import "../conditions/ha-visibility-status";
@@ -49,7 +49,7 @@ export class HuiBadgeVisibilityEditor extends LitElement {
private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation();
const conditions = ev.detail.value as VisibilityCondition[];
const conditions = ev.detail.value as Condition[];
const newConfig: LovelaceCardConfig = {
...this.config,
visibility: conditions,
@@ -5,7 +5,7 @@ import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { HomeAssistant } from "../../../../types";
import type { VisibilityCondition } from "../../common/validate-condition";
import type { Condition } from "../../common/validate-condition";
import { conditionsEntityContext } from "../conditions/context";
import "../conditions/ha-card-conditions-editor";
import "../conditions/ha-visibility-status";
@@ -49,7 +49,7 @@ export class HuiCardVisibilityEditor extends LitElement {
private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation();
const conditions = ev.detail.value as VisibilityCondition[];
const conditions = ev.detail.value as Condition[];
const newConfig: LovelaceCardConfig = {
...this.config,
visibility: conditions,
@@ -13,13 +13,7 @@ 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 { ensureArray } from "../../../../common/array/ensure-array";
import {
isLogicalCondition,
logicalChildren,
} from "../../../../common/condition/translate";
import type { ConditionEvaluation } from "../../../../common/controllers/condition-evaluator-controller";
import { ConditionEvaluatorController } from "../../../../common/controllers/condition-evaluator-controller";
import { ConditionListenersController } from "../../../../common/controllers/condition-listeners-controller";
import { storage } from "../../../../common/decorators/storage";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event";
@@ -38,37 +32,19 @@ import "../../../../components/ha-icon-button";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip";
import "../../../../components/ha-yaml-editor";
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 { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type {
NumericStateCondition as CoreNumericStateCondition,
StateCondition as CoreStateCondition,
} from "../../../../data/automation";
import {
CONDITION_ROW_CONFIG_KEYS,
pickRowConfig,
} 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 {
addEntityToCondition,
checkConditionsMet,
validateConditionalConfig,
} from "../../common/validate-condition";
import type { ConditionsEntityContext } from "./context";
@@ -95,129 +71,15 @@ const containsNoEntityCondition = (
): boolean =>
noEntity &&
CONTAINER_CONDITIONS.includes(condition.condition) &&
(
ensureArray(
(condition as OrCondition | AndCondition | NotCondition).conditions
) ?? []
).some(
(c) =>
NO_ENTITY_CONDITIONS.includes(c.condition) ||
containsNoEntityCondition(c, noEntity)
);
export const SERVER_EDITOR_CONDITIONS = ["template", "sun", "zone", "device"];
export const isServerEditorCondition = (condition: string): boolean =>
SERVER_EDITOR_CONDITIONS.includes(condition);
// Types entity filters can evaluate locally against each entity.
const FILTER_CONDITION_TYPES = new Set([
"state",
"numeric_state",
"screen",
"user",
"view_columns",
"location",
"time",
]);
// Usable as an entity filter: local types only, no pinned entity, list-shaped `and`/`or`/`not`.
export const isFilterCompatibleCondition = (
condition: VisibilityCondition
): boolean => {
if (isLogicalCondition(condition)) {
return (
(condition.conditions === undefined ||
Array.isArray(condition.conditions)) &&
logicalChildren(condition).every(isFilterCompatibleCondition)
);
}
if ("entity_id" in condition || !!(condition as { entity?: string }).entity) {
return false;
}
// `{ entity, state }` is a lovelace state condition.
if (!("condition" in condition)) {
return true;
}
return FILTER_CONDITION_TYPES.has(condition.condition);
};
// Uses the automation editor. Entity-filter state/numeric_state keep the lovelace editor.
export const usesAutomationConditionEditor = (
conditionType: string,
noEntity: boolean
): boolean =>
isServerEditorCondition(conditionType) ||
(!noEntity &&
(conditionType === "state" || conditionType === "numeric_state"));
// Show lovelace state/numeric_state in core form for the automation editor.
// Unlike `translateToCoreCondition`, incomplete configs stay editable.
// Entity-less conditions get `contextEntityId` so the field isn't empty.
const toCoreEditorCondition = (
condition: VisibilityCondition,
contextEntityId?: string
): VisibilityCondition => {
if ("entity_id" in condition) {
return condition;
}
// Keep `enabled` / `alias` / `note` so editing a disabled condition doesn't re-enable it.
const rowConfig = pickRowConfig(condition, CONDITION_ROW_CONFIG_KEYS);
// `{ entity, state }` has no `condition` key.
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`; show `not` wrapping a state condition.
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 { ...rowConfig, condition: "not", conditions: [inner] };
}
// Keep an empty `state` so incomplete configs stay editable.
const core: CoreStateCondition = {
...rowConfig,
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 = {
...rowConfig,
condition: "numeric_state",
entity_id: lovelace.entity || contextEntityId || "",
};
if (lovelace.attribute !== undefined) {
core.attribute = lovelace.attribute;
}
if (lovelace.above != null) {
core.above = lovelace.above;
}
if (lovelace.below != null) {
core.below = lovelace.below;
}
return core;
}
return condition;
};
(condition as OrCondition | AndCondition | NotCondition).conditions?.some(
(c) => NO_ENTITY_CONDITIONS.includes(c.condition)
) === true;
@customElement("ha-card-condition-editor")
export class HaCardConditionEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) condition!: VisibilityCondition;
@property({ attribute: false }) condition!: Condition | LegacyCondition;
@state()
@consume({ context: conditionsEntityContext, subscribe: true })
@@ -233,7 +95,7 @@ export class HaCardConditionEditor extends LitElement {
subscribe: false,
storage: "sessionStorage",
})
protected _clipboard?: VisibilityCondition;
protected _clipboard?: Condition | LegacyCondition;
@state() public _yamlMode = false;
@@ -250,23 +112,7 @@ export class HaCardConditionEditor extends LitElement {
message?: string;
} = { state: "unknown" };
private _conditionEvaluator = new ConditionEvaluatorController(this, {
// Debounce while typing (templates).
resubscribeDelay: 500,
onResult: (result, error) => this._setLiveTestResult(result, error),
});
// Folded observation, rebuilt only when the source or entity id changes.
private __observedSource?: VisibilityCondition;
private __observedEntityId?: string;
private __observed?: VisibilityCondition[];
private __clientInvalid = false;
// Pins the indicator when we skip the evaluator (hidden / invalid).
private _override?: LiveTestState;
private _listeners = new ConditionListenersController(this);
private get _editor() {
if (!this._condition) return undefined;
@@ -275,120 +121,82 @@ export class HaCardConditionEditor extends LitElement {
) as LovelaceConditionEditorConstructor | undefined;
}
private get _usesAutomationEditor(): boolean {
return (
!!this._condition &&
usesAutomationConditionEditor(this._condition.condition, this._noEntity)
);
}
// Filter-mode conditions have no entity to test against.
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 {
// Entity-less conditions pick up the card entity from context.
if (
changedProperties.has("condition") ||
(changedProperties as Map<string, unknown>).has("_entityContext")
) {
const normalized = {
if (changedProperties.has("condition")) {
this._condition = {
condition: "state",
...this.condition,
} as Condition;
const contextEntityId =
this._entityContext?.mode === "current"
? this._entityContext.entityId
: undefined;
this._condition = (
usesAutomationConditionEditor(normalized.condition, this._noEntity)
? toCoreEditorCondition(normalized, contextEntityId)
: normalized
) as Condition;
if (this._usesAutomationEditor) {
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;
};
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._updateLiveTest();
this._evaluateLiveTest();
}
}
protected updated(changedProperties: PropertyValues<this>): void {
if ((changedProperties as Map<string, unknown>).has("_entityContext")) {
this._updateLiveTest();
this._evaluateLiveTest();
}
}
private _liveTestContext(): ConditionContext {
return this._entityContext?.mode === "current"
? { entity_id: this._entityContext.entityId }
: {};
}
private _updateLiveTest() {
if (
!this.condition ||
!this._condition ||
this._hideLiveTest(this._condition)
) {
this._override = "unknown";
this._conditionEvaluator.observe(undefined, this.hass);
private _evaluateLiveTest() {
if (!this.condition || !this._condition) {
this._liveTestResult = { state: "unknown" };
return;
}
const entityId = this._liveTestContext().entity_id;
if (
this.condition !== this.__observedSource ||
entityId !== this.__observedEntityId
isNoEntityCondition(this._condition.condition, this._noEntity) ||
containsNoEntityCondition(this._condition, this._noEntity)
) {
this.__observedSource = this.condition;
this.__observedEntityId = entityId;
this.__clientInvalid = !validateConditionalConfig([this.condition]);
const observed = entityId
? addEntityToCondition(this.condition, entityId)
: this.condition;
this.__observed = [observed];
this._liveTestResult = {
state: "unknown",
message: this.hass.localize(
"ui.panel.lovelace.editor.condition-editor.live_test_state.unknown"
),
};
return;
}
if (this.__clientInvalid) {
this._override = "invalid";
this._conditionEvaluator.observe(undefined, this.hass);
if (!validateConditionalConfig([this.condition])) {
this._liveTestResult = {
state: "invalid",
message: this.hass.localize(
@@ -398,30 +206,15 @@ export class HaCardConditionEditor extends LitElement {
return;
}
if (this._override !== undefined) {
// Leaving a pinned branch; evaluator won't notify until a new result.
this._override = undefined;
this._liveTestResult = { state: "unknown" };
}
this._conditionEvaluator.observe(this.__observed, this.hass, () =>
this._liveTestContext()
);
}
private _setLiveTestResult(result: ConditionEvaluation, error?: string) {
if (this._override !== undefined) {
return;
}
if (error) {
this._liveTestResult = { state: "invalid", message: error };
return;
}
const liveState: LiveTestState =
result === "visible" ? "pass" : result === "hidden" ? "fail" : "unknown";
const testContext =
this._entityContext?.mode === "current"
? { entity_id: this._entityContext.entityId }
: {};
const pass = checkConditionsMet([this.condition], this.hass, testContext);
this._liveTestResult = {
state: liveState,
state: pass ? "pass" : "fail",
message: this.hass.localize(
`ui.panel.lovelace.editor.condition-editor.live_test_state.${liveState}`
`ui.panel.lovelace.editor.condition-editor.live_test_state.${pass ? "pass" : "fail"}`
),
};
}
@@ -431,7 +224,9 @@ export class HaCardConditionEditor extends LitElement {
if (!condition) return nothing;
const hideLiveTest = this._hideLiveTest(condition);
const hideLiveTest =
isNoEntityCondition(condition.condition, this._noEntity) ||
containsNoEntityCondition(condition, this._noEntity);
return html`
<div class="container">
@@ -500,7 +295,8 @@ export class HaCardConditionEditor extends LitElement {
</ha-icon-button>
${
hideLiveTest
isNoEntityCondition(condition.condition, this._noEntity) ||
containsNoEntityCondition(condition, this._noEntity)
? nothing
: html`<ha-dropdown-item value="test">
${this.hass.localize(
@@ -584,27 +380,18 @@ export class HaCardConditionEditor extends LitElement {
@value-changed=${this._onYamlChange}
></ha-yaml-editor>
`
: this._usesAutomationEditor
? html`
<ha-automation-condition-editor
.hass=${this.hass}
.condition=${condition}
.uiSupported=${true}
@ui-mode-not-available=${this._handleUiModeNotAvailable}
></ha-automation-condition-editor>
`
: html`
${dynamicElement(
getConditionClassName(
condition.condition,
this._noEntity
),
{
hass: this.hass,
condition: condition,
}
)}
`
: html`
${dynamicElement(
getConditionClassName(
condition.condition,
this._noEntity
),
{
hass: this.hass,
condition: condition,
}
)}
`
}
</div>
</ha-expansion-panel>
@@ -612,7 +399,7 @@ export class HaCardConditionEditor extends LitElement {
`;
}
private _handleAction(ev: HaDropdownSelectEvent) {
private async _handleAction(ev: HaDropdownSelectEvent) {
const action = ev.detail.item.value;
if (action === undefined) {
@@ -621,7 +408,7 @@ export class HaCardConditionEditor extends LitElement {
switch (action) {
case "test":
this._testCondition();
await this._testCondition();
return;
case "duplicate":
this._duplicateCondition();
@@ -642,18 +429,37 @@ export class HaCardConditionEditor extends LitElement {
private _timeout?: number;
private _testCondition() {
private async _testCondition() {
if (this._timeout) {
window.clearTimeout(this._timeout);
this._timeout = undefined;
}
// Skip the chip when the result is still unknown or already invalid.
const result = this._conditionEvaluator.result;
if (result === "unknown" || this._conditionEvaluator.error !== undefined) {
this._testingResult = 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"
),
});
return;
}
this._testingResult = result === "visible";
const testContext =
this._entityContext?.mode === "current"
? { entity_id: this._entityContext.entityId }
: {};
this._testingResult = checkConditionsMet(
[condition],
this.hass,
testContext
);
this._timeout = window.setTimeout(() => {
this._testingResult = undefined;
@@ -688,16 +494,6 @@ export class HaCardConditionEditor extends LitElement {
fireEvent(this, "value-changed", { value: ev.detail.value });
}
// Automation editors emit this when UI mode can't handle the config.
private _handleUiModeNotAvailable(ev: CustomEvent) {
ev.stopPropagation();
this._uiWarnings = handleStructError(this.hass, ev.detail).warnings;
this._uiAvailable = false;
if (!this._yamlMode) {
this._yamlMode = true;
}
}
static styles = [
haStyle,
css`
@@ -745,6 +541,6 @@ declare global {
}
interface HASSDomEvents {
"duplicate-condition": { value: VisibilityCondition };
"duplicate-condition": { value: Condition | LegacyCondition };
}
}
@@ -15,7 +15,7 @@ import type { HomeAssistant } from "../../../../types";
import { ICON_CONDITION } from "../../common/icon-condition";
import type {
Condition,
VisibilityCondition,
LegacyCondition,
} from "../../common/validate-condition";
import type { ConditionsEntityContext } from "./context";
import { conditionsEntityContext } from "./context";
@@ -23,17 +23,16 @@ import "./ha-card-condition-editor";
import {
type HaCardConditionEditor,
getConditionClassName,
isFilterCompatibleCondition,
isServerEditorCondition,
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";
@@ -45,14 +44,10 @@ const UI_CONDITION = [
"screen",
"time",
"user",
"template",
"sun",
"zone",
"device",
"and",
"not",
"or",
] as const satisfies readonly string[];
] as const satisfies readonly Condition["condition"][];
@customElement("ha-card-conditions-editor")
export class HaCardConditionsEditor extends LitElement {
@@ -64,9 +59,11 @@ export class HaCardConditionsEditor extends LitElement {
subscribe: false,
storage: "sessionStorage",
})
protected _clipboard?: VisibilityCondition;
protected _clipboard?: Condition | LegacyCondition;
@property({ attribute: false }) public conditions!: VisibilityCondition[];
@property({ attribute: false }) public conditions!: (
Condition | LegacyCondition
)[];
@state()
@consume({ context: conditionsEntityContext, subscribe: true })
@@ -79,9 +76,6 @@ export class HaCardConditionsEditor extends LitElement {
private _focusLastConditionOnChange = false;
protected firstUpdated() {
// Automation condition editors read labels from the config fragment.
this.hass.loadFragmentTranslation("config");
// Expand the condition if there is only one
if (this.conditions.length === 1) {
const row = this.shadowRoot!.querySelector<HaCardConditionEditor>(
@@ -111,21 +105,6 @@ export class HaCardConditionsEditor extends LitElement {
}
}
// Entity filters still evaluate locally; don't offer server-only types there.
private get _availableConditions(): readonly string[] {
return this._noEntity
? UI_CONDITION.filter((condition) => !isServerEditorCondition(condition))
: UI_CONDITION;
}
// Clipboard is shared; a visibility condition may not be valid as a filter.
private get _canPaste(): boolean {
return (
this._clipboard !== undefined &&
(!this._noEntity || isFilterCompatibleCondition(this._clipboard))
);
}
protected render() {
return html`
<div class="conditions">
@@ -149,7 +128,7 @@ export class HaCardConditionsEditor extends LitElement {
)}
</ha-button>
${
this._canPaste
this._clipboard
? html`
<ha-dropdown-item value="paste">
${this.hass.localize(
@@ -163,7 +142,7 @@ export class HaCardConditionsEditor extends LitElement {
`
: nothing
}
${this._availableConditions.map(
${UI_CONDITION.map(
(condition) => html`
<ha-dropdown-item .value=${condition}>
${
@@ -185,28 +164,17 @@ export class HaCardConditionsEditor extends LitElement {
}
private _addCondition(ev: HaDropdownSelectEvent) {
const value = ev.detail.item.value as string;
const condition = ev.detail.item.value as "paste" | Condition["condition"];
const conditions = [...this.conditions];
if (!value || (value === "paste" && !this._canPaste)) {
if (!condition || (condition === "paste" && !this._clipboard)) {
return;
}
if (value === "paste") {
if (condition === "paste") {
const newCondition = deepClone(this._clipboard!);
conditions.push(newCondition);
} else if (usesAutomationConditionEditor(value, this._noEntity)) {
// Seed from the automation 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,31 +1,29 @@
import { consume } from "@lit/context";
import { mdiAlertCircle, mdiEye, mdiEyeOff, mdiHelpCircle } from "@mdi/js";
import { mdiAlertCircle, mdiEye, mdiEyeOff } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { ConditionEvaluation } from "../../../../common/controllers/condition-evaluator-controller";
import { ConditionEvaluatorController } from "../../../../common/controllers/condition-evaluator-controller";
import { ConditionListenersController } from "../../../../common/controllers/condition-listeners-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 {
ConditionContext,
VisibilityCondition,
Condition,
LegacyCondition,
} from "../../common/validate-condition";
import {
addEntityToCondition,
checkConditionsMet,
validateConditionalConfig,
} from "../../common/validate-condition";
import type { ConditionsEntityContext } from "./context";
import { conditionsEntityContext } from "./context";
type VisibilityState = "visible" | "hidden" | "unknown" | "invalid";
type VisibilityState = "visible" | "hidden" | "invalid";
const STATE_ICONS: Record<VisibilityState, string> = {
visible: mdiEye,
hidden: mdiEyeOff,
unknown: mdiHelpCircle,
invalid: mdiAlertCircle,
};
@@ -33,16 +31,17 @@ const STATE_ICONS: Record<VisibilityState, string> = {
* @element ha-visibility-status
*
* @summary
* Alert banner for the live visibility result of a condition set.
* Alert banner that surfaces the live visibility result for a set of
* lovelace conditions.
*
* @attr {"visible"|"hidden"|"unknown"|"invalid"} state - Computed visibility state
* @attr {"visible"|"hidden"|"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: VisibilityCondition[] = [];
public conditions: (Condition | LegacyCondition)[] = [];
@state()
@consume({ context: conditionsEntityContext, subscribe: true })
@@ -51,24 +50,17 @@ export class HaVisibilityStatus extends LitElement {
@property()
public state: VisibilityState = "visible";
private _conditionEvaluator = new ConditionEvaluatorController(this, {
resubscribeDelay: 500,
onResult: (result, error) => this._applyResult(result, error),
});
// Folded observation; `_override` pins empty / invalid without the controller.
private __observedSource?: VisibilityCondition[];
private __observedEntityId?: string;
private __observed?: VisibilityCondition[];
private __clientInvalid = false;
private _override?: VisibilityState;
private _listeners = new ConditionListenersController(this);
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") ||
@@ -86,9 +78,7 @@ export class HaVisibilityStatus extends LitElement {
? "success"
: this.state === "hidden"
? "warning"
: this.state === "unknown"
? "info"
: "error"
: "error"
}
>
<ha-svg-icon slot="icon" .path=${STATE_ICONS[this.state]}></ha-svg-icon>
@@ -106,68 +96,27 @@ 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) {
this._override = "visible";
this._conditionEvaluator.observe(undefined, this.hass);
this.state = "visible";
return;
}
const entityId =
this._entityContext?.mode === "current"
? this._entityContext.entityId
: undefined;
if (
conditions !== this.__observedSource ||
entityId !== this.__observedEntityId
) {
this.__observedSource = conditions;
this.__observedEntityId = entityId;
this.__clientInvalid = !validateConditionalConfig(conditions);
this.__observed = entityId
? conditions.map((c) => addEntityToCondition(c, entityId))
: conditions;
}
if (this.__clientInvalid) {
this._override = "invalid";
this._conditionEvaluator.observe(undefined, this.hass);
this.state = "invalid";
return;
}
if (this._override !== undefined) {
// Leaving a pinned branch; evaluator won't notify until a new result.
this._override = undefined;
this.state = "unknown";
}
this._conditionEvaluator.observe(this.__observed, this.hass, () =>
this._context()
);
}
private _applyResult(result: ConditionEvaluation, error?: string) {
// Empty / invalid branches pin the state; ignore the torn-down controller.
if (this._override !== undefined) {
return;
}
this.state = error
? "invalid"
: result === "visible"
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"
: result === "hidden"
? "hidden"
: "unknown";
: "hidden";
}
if (newState === this.state) {
return;
}
this.state = newState;
}
static styles: CSSResultGroup = [
@@ -1,6 +1,6 @@
import { consume } from "@lit/context";
import { html, LitElement } from "lit";
import { property, state } from "lit/decorators";
import { customElement, 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,8 +40,7 @@ interface NumericStateConditionData {
below?: number | string;
}
// Base for the entity-filter (no-entity) numeric_state editor. Not registered
// itself; dashboard editing uses the automation condition editor.
@customElement("ha-card-condition-numeric_state")
export class HaCardConditionNumericState extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -212,3 +211,9 @@ 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 { property, state } from "lit/decorators";
import { customElement, 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,8 +37,7 @@ interface StateConditionData {
state?: string | string[];
}
// Base for the entity-filter (no-entity) state editor. Not registered itself;
// dashboard editing uses the automation condition editor.
@customElement("ha-card-condition-state")
export class HaCardConditionState extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -229,3 +228,9 @@ export class HaCardConditionState extends LitElement {
}
};
}
declare global {
interface HTMLElementTagNameMap {
"ha-card-condition-state": HaCardConditionState;
}
}
@@ -17,7 +17,7 @@ import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
import type { HaFormSchema } from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type { SankeyCardConfig } from "../../cards/types";
import type { EnergyCardSankeyConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
@@ -38,7 +38,6 @@ const cardConfigStruct = assign(
group_by_floor: optional(boolean()),
group_by_area: optional(boolean()),
max_devices: optional(number()),
show_values: optional(boolean()),
})
);
@@ -51,9 +50,9 @@ export class HuiEnergySankeyCardEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: SankeyCardConfig;
@state() private _config?: EnergyCardSankeyConfig;
public setConfig(config: SankeyCardConfig): void {
public setConfig(config: EnergyCardSankeyConfig): void {
assert(config, cardConfigStruct);
this._config = config;
}
@@ -93,11 +92,6 @@ export class HuiEnergySankeyCardEditor
required: false,
selector: { boolean: {} },
},
{
name: "show_values",
required: false,
selector: { boolean: {} },
},
],
},
{
@@ -162,7 +156,6 @@ export class HuiEnergySankeyCardEditor
case "group_by_floor":
case "group_by_area":
case "max_devices":
case "show_values":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.energy-sankey.${schema.name}`
);
@@ -25,9 +25,6 @@ const NON_STANDARD_CARD_URLS = {
"energy-devices-graph": "energy/#devices-energy-graph",
"energy-devices-detail-graph": "energy/#detail-devices-energy-graph",
"energy-sankey": "energy/#sankey-energy-graph",
"power-sankey": "energy/#power-flow-sankey-graph",
"water-sankey": "energy/#water-sankey-graph",
"water-flow-sankey": "energy/#water-flow-sankey-graph",
"power-sources-graph": "energy/#power-sources-graph",
};
@@ -11,7 +11,7 @@ import type {
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type { VisibilityCondition } from "../../common/validate-condition";
import type { Condition } from "../../common/validate-condition";
import type { ButtonHeadingBadgeConfig } from "../../heading-badges/types";
import type { LovelaceGenericElementEditor } from "../../types";
import "../conditions/ha-card-conditions-editor";
@@ -172,7 +172,7 @@ export class HuiButtonHeadingBadgeEditor
return;
}
const conditions = ev.detail.value as VisibilityCondition[];
const conditions = ev.detail.value as Condition[];
const newConfig: ButtonHeadingBadgeConfig = {
...this._config,
@@ -23,7 +23,7 @@ import type {
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type { VisibilityCondition } from "../../common/validate-condition";
import type { Condition } from "../../common/validate-condition";
import type { EntityHeadingBadgeConfig } from "../../heading-badges/types";
import type { LovelaceGenericElementEditor } from "../../types";
import { ACTION_RELATED_CONTEXT } from "../../components/hui-action-editor";
@@ -277,7 +277,7 @@ export class HuiHeadingEntityEditor
return;
}
const conditions = ev.detail.value as VisibilityCondition[];
const conditions = ev.detail.value as Condition[];
const newConfig: EntityHeadingBadgeConfig = {
...this._config,
@@ -3,7 +3,7 @@ import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
import type { HomeAssistant } from "../../../../types";
import type { VisibilityCondition } from "../../common/validate-condition";
import type { Condition } from "../../common/validate-condition";
import "../conditions/ha-card-conditions-editor";
import "../conditions/ha-visibility-status";
@@ -37,7 +37,7 @@ export class HuiDialogEditSection extends LitElement {
private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation();
const conditions = ev.detail.value as VisibilityCondition[];
const conditions = ev.detail.value as Condition[];
const newConfig: LovelaceSectionRawConfig = {
...this.config,
visibility: conditions,
@@ -1,10 +1,10 @@
import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement } 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 { validateConditionalConfig } from "../common/validate-condition";
import {
checkConditionsMet,
validateConditionalConfig,
} from "../common/validate-condition";
import type { LovelacePictureElementEditor } from "../types";
import type {
ConditionalElementConfig,
@@ -13,25 +13,18 @@ import type {
} from "./types";
@customElement("hui-conditional-element")
class HuiConditionalElement
extends ConditionalListenerMixin<ConditionalElementConfig>(ReactiveElement)
implements LovelaceElement
{
class HuiConditionalElement extends HTMLElement 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");
}
@property({ attribute: false }) public hass?: HomeAssistant;
public _hass?: HomeAssistant;
@state() protected _config?: ConditionalElementConfig;
private _config?: ConditionalElementConfig;
private _elements: LovelaceElement[] = [];
protected createRenderRoot() {
return this;
}
public setConfig(config: ConditionalElementConfig): void {
if (
!config.conditions ||
@@ -43,53 +36,46 @@ class HuiConditionalElement
throw new Error("Invalid configuration");
}
this._elements.forEach((el) => el.parentElement?.removeChild(el));
this._elements = [];
if (this._elements.length > 0) {
this._elements.forEach((el: LovelaceElement) => {
if (el.parentElement) {
el.parentElement.removeChild(el);
}
});
config.elements.forEach((elementConfig: LovelaceElementConfig) => {
this._elements = [];
}
this._config = config;
this._config.elements.forEach((elementConfig: LovelaceElementConfig) => {
this._elements.push(createStyledHuiElement(elementConfig));
});
this._config = config;
this._updateElements();
}
public connectedCallback() {
super.connectedCallback();
this._updateVisibility();
set hass(hass: HomeAssistant) {
this._hass = hass;
this._updateElements();
}
protected setupConditionalListeners() {
if (!this._config) {
private _updateElements() {
if (!this._hass || !this._config) {
return;
}
super.setupConditionalListeners(this._config.conditions);
}
const visible = checkConditionsMet(this._config.conditions, this._hass, {});
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) => {
this._elements.forEach((el: LovelaceElement) => {
if (visible) {
el.hass = this.hass;
el.hass = this._hass;
if (!el.parentElement) {
this.appendChild(el);
}
} else if (el.parentElement) {
this.removeChild(el);
el.parentElement.removeChild(el);
}
});
}
+2 -2
View File
@@ -2,7 +2,7 @@ import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { ActionHandlerOptions } from "../../../data/lovelace/action_handler";
import type { ActionConfig } from "../../../data/lovelace/config/action";
import type { HomeAssistant } from "../../../types";
import type { VisibilityCondition } from "../common/validate-condition";
import type { Condition } from "../common/validate-condition";
import type { HuiImage } from "../components/hui-image";
import type { MediaSelectorValue } from "../../../data/selector";
@@ -47,7 +47,7 @@ export interface LovelaceElementHitInfo {
}
export interface ConditionalElementConfig extends LovelaceElementConfigBase {
conditions: VisibilityCondition[];
conditions: Condition[];
elements: LovelaceElementConfigBase[];
title?: string;
}
+2 -5
View File
@@ -5,10 +5,7 @@ import type {
} from "../../../data/lovelace/config/action";
import type { HomeAssistant } from "../../../types";
import type { LegacyStateFilter } from "../common/evaluate-filter";
import type {
Condition,
VisibilityCondition,
} from "../common/validate-condition";
import type { Condition } from "../common/validate-condition";
import type { TimestampRenderingFormat } from "../components/types";
export interface EntityConfig {
@@ -104,7 +101,7 @@ export interface LovelaceRow extends HTMLElement {
export interface ConditionalRowConfig extends EntityConfig {
row: EntityConfig;
conditions: VisibilityCondition[];
conditions: Condition[];
}
export interface AttributeRowConfig extends EntityConfig {
@@ -5,6 +5,7 @@ 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";
@@ -159,7 +160,14 @@ export class HuiHeadingBadge extends ConditionalListenerMixin<LovelaceHeadingBad
return;
}
const visible = conditionsMet ?? this._conditionsVisible();
const visible =
conditionsMet ??
(!this.config?.visibility ||
checkConditionsMet(
this.config.visibility,
this.hass,
this._conditionContext
));
this._setElementVisibility(visible);
}
+2 -2
View File
@@ -1,10 +1,10 @@
import type { ActionConfig } from "../../../data/lovelace/config/action";
import type { VisibilityCondition } from "../common/validate-condition";
import type { Condition } from "../common/validate-condition";
export interface LovelaceHeadingBadgeConfig {
type?: string;
[key: string]: any;
visibility?: VisibilityCondition[];
visibility?: Condition[];
}
export interface ErrorBadgeConfig extends LovelaceHeadingBadgeConfig {
+9 -4
View File
@@ -19,6 +19,7 @@ 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";
@@ -228,9 +229,6 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
}
this._config = sectionConfig;
// `_config` isn't reactive; strategy sections assign it after the last
// update, so re-feed visibility now.
this.setupConditionalListeners();
// Apply theme now that config is set (after potential strategy await)
applyThemesOnElement(this, this.hass!.themes, this._config.theme);
@@ -282,7 +280,14 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
return;
}
const visible = conditionsMet ?? this._conditionsVisible();
const visible =
conditionsMet ??
(!this._config.visibility ||
checkConditionsMet(
this._config.visibility,
this.hass,
this._conditionContext
));
if (!visible) {
this._setElementVisibility(false);
@@ -6,6 +6,7 @@ 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";
@@ -36,7 +37,14 @@ export class HuiViewSidebar extends ConditionalListenerMixin<LovelaceViewSidebar
protected _updateVisibility(conditionsMet?: boolean) {
if (!this.hass || !this.config) return;
const visible = conditionsMet ?? this._conditionsVisible();
const visible =
conditionsMet ??
(!this.config.visibility ||
checkConditionsMet(
this.config.visibility,
this.hass,
this._conditionContext
));
if (visible !== this._visible) {
this._visible = visible;
-6
View File
@@ -972,11 +972,6 @@ const HA_FUNCTION_DEFS: HaFunctionDef[] = [
"days?, seconds?, microseconds?, milliseconds?, minutes?, hours?, weeks?",
info: "Creates a timedelta object.",
},
{
snippet: "timedelta_string(${value}, ${precision})",
detail: "value, precision?",
info: "Formats a timedelta object as a human-readable duration.",
},
{
snippet: 'timestamp_custom(${timestamp}, "${format}")',
detail: "timestamp, format, local_time?",
@@ -1892,7 +1887,6 @@ const DOCUMENTED_NAMES = new Set<string>([
"time_since",
"time_until",
"timedelta",
"timedelta_string",
"timestamp_custom",
"timestamp_local",
"timestamp_utc",
+10 -48
View File
@@ -637,6 +637,12 @@
"entity": "Entity",
"from": "From",
"to": "To",
"row_label": {
"above": "above {value}",
"below": "below {value}",
"between": "in range {value}",
"outside": "outside {value}"
},
"crossed": {
"type": "Fire when the value goes",
"above": "Above",
@@ -1154,11 +1160,7 @@
"category": "Category"
},
"map": {
"error": "Unable to load map",
"editing_unavailable": "Dragging markers and resizing zones aren't available on this map.",
"location": "Location",
"radius": "Radius in meters",
"radius_of": "Radius of {name} in meters"
"error": "Unable to load map"
},
"statistics_charts": {
"loading_statistics": "Loading statistics…",
@@ -2835,10 +2837,6 @@
"main": "Serial",
"secondary": "Serial ports and the integrations and apps using them"
},
"modbus": {
"main": "Modbus",
"secondary": "Industrial devices sharing a serial or network link"
},
"knx": {
"main": "KNX",
"secondary": "Building automation standard"
@@ -7639,7 +7637,6 @@
"consumer_not_running": "{name} (not running)",
"can_be_used_with": "Can be used with {integrations}",
"discovered_by": "Discovered by {integration}, set it up",
"used_by_modbus": "Used by Modbus",
"port_information": "Port information",
"fields": {
"device": "Device",
@@ -7654,26 +7651,6 @@
"bcd_device": "Device revision"
}
},
"modbus": {
"title": "Modbus",
"status_summary": "{units, plural,\n one {# unit}\n other {# units}\n} on {total, plural,\n one {# connection}\n other {# connections}\n}",
"connections": "Connections",
"connections_description": "Every integration talking to a Modbus device shares one connection to it.",
"state_connected": "Connected",
"state_not_connected": "Not connected",
"state_open": "Open",
"state_closed": "Closed",
"no_connections": "No Modbus connections",
"no_connections_description": "A connection opens as soon as an integration starts talking to a Modbus device.",
"loading_error": "Failed to load Modbus connections",
"units": "{count, plural,\n one {Unit {ids}}\n other {Units {ids}}\n}",
"view_serial_port": "View this port under Serial",
"transport": {
"tcp": "TCP",
"udp": "UDP",
"serial": "Serial"
}
},
"dhcp": {
"title": "DHCP discovery",
"mac_address": "MAC address",
@@ -9902,15 +9879,13 @@
"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": "Invalid configuration",
"headline": "Visibility status unknown",
"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",
@@ -9964,18 +9939,6 @@
},
"and": {
"label": "And"
},
"template": {
"label": "Template"
},
"sun": {
"label": "Sun"
},
"zone": {
"label": "Zone"
},
"device": {
"label": "Device"
}
}
},
@@ -10242,7 +10205,6 @@
"group_by_area": "Group by area",
"max_devices": "Maximum devices per parent",
"max_devices_description": "Devices beyond this limit are grouped into an \"Other\" node, smallest first. The limit applies per upstream device, not per floor or area. Defaults to 20.",
"show_values": "Show values",
"layout": "Graph direction",
"layout_directions": {
"auto": "Automatic",
+10 -17
View File
@@ -26,20 +26,13 @@ yarn test:bench # run all benchmarks
yarn test:bench down-sample # run one suite
# Record a baseline, then compare after a change:
yarn test:bench --reporter=default --reporter=json --outputFile=test/benchmarks/results/baseline.json
yarn test:bench --reporter=default --reporter=json --outputFile=test/benchmarks/results/after.json
yarn test:bench --outputJson test/benchmarks/results/baseline.json
yarn test:bench --compare test/benchmarks/results/baseline.json --outputJson test/benchmarks/results/after.json
```
`test/benchmarks/results/` is gitignored. Vitest 5 stores benchmark results
under `testResults[].assertionResults[].benchmarks[].tasks[]`. Each task has
`throughput.mean` (ops/sec), `latency.mean` (ms), `latency.rme` (relative
margin of error), and latency percentiles.
Vitest 5 removed `--outputJson` and `--compare`. Compare matching test and
task names in the two JSON reports, or use
[`writeResult` and `bench.from()`](https://vitest.dev/guide/benchmarking#storing-and-replaying-results)
for an in-test comparison. Record fresh baselines after migrating from
Vitest 4; its reports use a different format and benchmark engine version.
`test/benchmarks/results/` is gitignored. The JSON reports include `hz`,
`mean`, `rme` (relative margin of error), and percentiles per benchmark;
compare mode also prints the delta next to each result.
Benchmarks run in a plain node environment (`test/vitest.bench.config.ts`)
with sequential files for stable timings. Expect run-to-run noise of a few
@@ -110,17 +103,17 @@ Work on **one target at a time**:
2. **Coverage check** — confirm the target has characterization coverage for
the code paths you will touch; add missing cases in a separate commit
_before_ changing the implementation.
3. **Baseline**: run `yarn test:bench --reporter=json --outputFile=.../baseline.json`
3. **Baseline** run `yarn test:bench --outputJson .../baseline.json`
**twice**; note the `rme` and the spread between runs. That spread is your
noise floor.
4. **Optimize** — change the implementation. Stay within the guardrails
below.
5. **Verify correctness**`yarn test` and `yarn lint` must pass. Never run
`yarn lint:types` with file arguments.
6. **Measure**: run `yarn test:bench --reporter=json --outputFile=.../after.json`
and compare matching test and benchmark task names against the baseline report.
7. **Report** — include a before/after table (`latency.mean`, `throughput.mean`,
`latency.rme`) for every affected benchmark, generated from the two JSON files.
6. **Measure**`yarn test:bench --compare .../baseline.json --outputJson
.../after.json`.
7. **Report** — include a before/after table (`mean`, `hz`, `rme`) for every
affected benchmark, generated from the two JSON files.
### Guardrails
+9 -15
View File
@@ -1,4 +1,4 @@
import { describe, test } from "vitest";
import { bench, describe } from "vitest";
import type { BarSeriesOption } from "echarts/types/dist/shared";
import { fillDataGapsAndRoundCaps } from "../../src/components/chart/round-caps";
import { computeYAxisFractionDigits } from "../../src/components/chart/y-axis-fraction-digits";
@@ -31,25 +31,19 @@ const buildBarDatasets = (
describe("fillDataGapsAndRoundCaps", () => {
// fillDataGapsAndRoundCaps mutates its input, so rebuild per iteration;
// build cost is included in both baseline and comparison runs.
test("stacked, 8 series, month of hourly bars", async ({ bench }) => {
await bench("stacked, 8 series, month of hourly bars", () => {
fillDataGapsAndRoundCaps(buildBarDatasets(1, true), true);
}).run();
bench("stacked, 8 series, month of hourly bars", () => {
fillDataGapsAndRoundCaps(buildBarDatasets(1, true), true);
});
test("non-stacked, 8 series, month of hourly bars", async ({ bench }) => {
await bench("non-stacked, 8 series, month of hourly bars", () => {
fillDataGapsAndRoundCaps(buildBarDatasets(2, false), false);
}).run();
bench("non-stacked, 8 series, month of hourly bars", () => {
fillDataGapsAndRoundCaps(buildBarDatasets(2, false), false);
});
});
describe("computeYAxisFractionDigits", () => {
test("typical ranges", async ({ bench }) => {
await bench("typical ranges", () => {
computeYAxisFractionDigits(0, 100);
computeYAxisFractionDigits(1.85, 2.0);
computeYAxisFractionDigits(0, 0.005);
}).run();
bench("typical ranges", () => {
computeYAxisFractionDigits(0, 100);
computeYAxisFractionDigits(1.85, 2.0);
computeYAxisFractionDigits(0, 0.005);
});
});
+35 -29
View File
@@ -1,4 +1,4 @@
import { describe, test } from "vitest";
import { bench, describe } from "vitest";
import { downSampleLineData } from "../../src/components/chart/down-sample";
import { FIXED_EPOCH_MS, SCALES } from "../fixtures/history-states";
import { createSeededRandom } from "../fixtures/random";
@@ -37,45 +37,51 @@ const largeMostlyGaps = withGaps(
);
describe("downSampleLineData", () => {
test("min/max small (1k points)", async ({ bench }) => {
await bench("min/max small (1k points)", () => {
downSampleLineData(small, MAX_DETAILS);
}).run();
bench("min/max small (1k points)", () => {
downSampleLineData(small, MAX_DETAILS);
});
test("min/max medium (10k points)", async ({ bench }) => {
await bench("min/max medium (10k points)", () => {
downSampleLineData(medium, MAX_DETAILS);
}).run();
bench("min/max medium (10k points)", () => {
downSampleLineData(medium, MAX_DETAILS);
});
test("min/max large (100k points)", async ({ bench }) => {
await bench("min/max large (100k points)", () => {
bench(
"min/max large (100k points)",
() => {
downSampleLineData(large, MAX_DETAILS);
}).run({ time: 1000, warmupIterations: 2 });
});
},
{ time: 1000, warmupIterations: 2 }
);
test("mean large (100k points)", async ({ bench }) => {
await bench("mean large (100k points)", () => {
bench(
"mean large (100k points)",
() => {
downSampleLineData(large, MAX_DETAILS, undefined, undefined, true);
}).run({ time: 1000, warmupIterations: 2 });
});
},
{ time: 1000, warmupIterations: 2 }
);
test("min/max large object points (100k points)", async ({ bench }) => {
await bench("min/max large object points (100k points)", () => {
bench(
"min/max large object points (100k points)",
() => {
downSampleLineData(largeObjects, MAX_DETAILS);
}).run({ time: 1000, warmupIterations: 2 });
});
},
{ time: 1000, warmupIterations: 2 }
);
test("min/max large with a few gaps (100k points)", async ({ bench }) => {
await bench("min/max large with a few gaps (100k points)", () => {
bench(
"min/max large with a few gaps (100k points)",
() => {
downSampleLineData(largeFewGaps, MAX_DETAILS);
}).run({ time: 1000, warmupIterations: 2 });
});
},
{ time: 1000, warmupIterations: 2 }
);
test("min/max large mostly gaps (100k points)", async ({ bench }) => {
await bench("min/max large mostly gaps (100k points)", () => {
bench(
"min/max large mostly gaps (100k points)",
() => {
downSampleLineData(largeMostlyGaps, MAX_DETAILS);
}).run({ time: 1000, warmupIterations: 2 });
});
},
{ time: 1000, warmupIterations: 2 }
);
});
+21 -23
View File
@@ -1,4 +1,4 @@
import { describe, test } from "vitest";
import { bench, describe } from "vitest";
import { generateEnergyGasGraphData } from "../../src/panels/lovelace/cards/energy/energy-gas-graph-data";
import type { EnergyPreferences } from "../../src/data/energy";
import { createMockComputedStyle } from "../fixtures/computed-style";
@@ -50,36 +50,34 @@ const large = generateEnergyData(3, {
});
describe("generateEnergyGasGraphData", () => {
test("small (1 day hourly, 2 sources)", async ({ bench }) => {
await bench("small (1 day hourly, 2 sources)", () => {
generateEnergyGasGraphData({
hass,
energyData: small,
computedStyles,
now,
});
}).run();
bench("small (1 day hourly, 2 sources)", () => {
generateEnergyGasGraphData({
hass,
energyData: small,
computedStyles,
now,
});
});
test("medium (month hourly + compare, 3 sources)", async ({ bench }) => {
await bench("medium (month hourly + compare, 3 sources)", () => {
generateEnergyGasGraphData({
hass,
energyData: medium,
computedStyles,
now,
});
}).run();
bench("medium (month hourly + compare, 3 sources)", () => {
generateEnergyGasGraphData({
hass,
energyData: medium,
computedStyles,
now,
});
});
test("large (month 5-minute + compare, 4 sources)", async ({ bench }) => {
await bench("large (month 5-minute + compare, 4 sources)", () => {
bench(
"large (month 5-minute + compare, 4 sources)",
() => {
generateEnergyGasGraphData({
hass,
energyData: large,
computedStyles,
now,
});
}).run({ time: 1000, warmupIterations: 2 });
});
},
{ time: 1000, warmupIterations: 2 }
);
});
@@ -1,4 +1,4 @@
import { describe, test } from "vitest";
import { bench, describe } from "vitest";
import { generateEnergySolarGraphData } from "../../src/panels/lovelace/cards/energy/energy-solar-graph-data";
import type {
EnergyPreferences,
@@ -91,32 +91,29 @@ const forecasts = buildForecasts(31 * 24, 60 * 60 * 1000, [
]);
describe("generateEnergySolarGraphData", () => {
test("small (2 days hourly, 1 source)", async ({ bench }) => {
await bench("small (2 days hourly, 1 source)", () => {
generateEnergySolarGraphData({
hass,
energyData: { ...small },
forecasts: undefined,
computedStyles,
now: new Date(FIXED_EPOCH_MS + 2 * dayMs),
});
}).run();
bench("small (2 days hourly, 1 source)", () => {
generateEnergySolarGraphData({
hass,
energyData: { ...small },
forecasts: undefined,
computedStyles,
now: new Date(FIXED_EPOCH_MS + 2 * dayMs),
});
});
test("medium (month hourly + compare, 2 sources)", async ({ bench }) => {
await bench("medium (month hourly + compare, 2 sources)", () => {
generateEnergySolarGraphData({
hass,
energyData: { ...medium },
forecasts: undefined,
computedStyles,
now: new Date(FIXED_EPOCH_MS + 31 * dayMs),
});
}).run();
bench("medium (month hourly + compare, 2 sources)", () => {
generateEnergySolarGraphData({
hass,
energyData: { ...medium },
forecasts: undefined,
computedStyles,
now: new Date(FIXED_EPOCH_MS + 31 * dayMs),
});
});
test("large (month 5-minute + compare, 2 sources)", async ({ bench }) => {
await bench("large (month 5-minute + compare, 2 sources)", () => {
bench(
"large (month 5-minute + compare, 2 sources)",
() => {
generateEnergySolarGraphData({
hass,
energyData: { ...large },
@@ -124,20 +121,17 @@ describe("generateEnergySolarGraphData", () => {
computedStyles,
now: new Date(FIXED_EPOCH_MS + 31 * dayMs),
});
}).run({ time: 1000, warmupIterations: 2 });
});
},
{ time: 1000, warmupIterations: 2 }
);
test("with forecast (month hourly, 2 sources + forecast)", async ({
bench,
}) => {
await bench("with forecast (month hourly, 2 sources + forecast)", () => {
generateEnergySolarGraphData({
hass,
energyData: { ...forecastData },
forecasts,
computedStyles,
now: new Date(FIXED_EPOCH_MS + 31 * dayMs),
});
}).run();
bench("with forecast (month hourly, 2 sources + forecast)", () => {
generateEnergySolarGraphData({
hass,
energyData: { ...forecastData },
forecasts,
computedStyles,
now: new Date(FIXED_EPOCH_MS + 31 * dayMs),
});
});
});
+17 -27
View File
@@ -1,4 +1,4 @@
import { describe, test } from "vitest";
import { bench, describe } from "vitest";
import type { LineSeriesOption } from "echarts/charts";
import {
computeConsumptionData,
@@ -39,45 +39,35 @@ const buildLineDatasets = (seed: number): LineSeriesOption[] => {
};
describe("getSummedData", () => {
test("month of hourly data, full source setup", async ({ bench }) => {
await bench("month of hourly data, full source setup", () => {
getSummedData({ ...monthHourly });
}).run();
bench("month of hourly data, full source setup", () => {
getSummedData({ ...monthHourly });
});
test("month of hourly data with compare", async ({ bench }) => {
await bench("month of hourly data with compare", () => {
getSummedData({ ...monthHourlyCompare });
}).run();
bench("month of hourly data with compare", () => {
getSummedData({ ...monthHourlyCompare });
});
});
describe("computeConsumptionData", () => {
test("month of hourly summed data", async ({ bench }) => {
await bench("month of hourly summed data", () => {
computeConsumptionData({ ...summedMonth }, undefined);
}).run();
bench("month of hourly summed data", () => {
computeConsumptionData({ ...summedMonth }, undefined);
});
});
describe("computeConsumptionSingle", () => {
test("full battery + solar flow", async ({ bench }) => {
await bench("full battery + solar flow", () => {
computeConsumptionSingle({
from_grid: 2,
to_grid: 1,
solar: 6,
to_battery: 3,
from_battery: 2,
});
}).run();
bench("full battery + solar flow", () => {
computeConsumptionSingle({
from_grid: 2,
to_grid: 1,
solar: 6,
to_battery: 3,
from_battery: 2,
});
});
});
describe("fillLineGaps", () => {
test("10 series, month of hourly buckets, 30% gaps", async ({ bench }) => {
await bench("10 series, month of hourly buckets, 30% gaps", () => {
fillLineGaps(buildLineDatasets(4).map((d) => ({ ...d })));
}).run();
bench("10 series, month of hourly buckets, 30% gaps", () => {
fillLineGaps(buildLineDatasets(4).map((d) => ({ ...d })));
});
});
+11 -14
View File
@@ -1,4 +1,4 @@
import { describe, test } from "vitest";
import { bench, describe } from "vitest";
import { HistoryStream } from "../../src/data/history";
import type { HistoryStreamMessage } from "../../src/data/history";
import { generateNumericSensorStates } from "../fixtures/history-states";
@@ -48,18 +48,15 @@ for (let i = 0; i < 20; i++) {
}
describe("HistoryStream.processMessage", () => {
test("initial chunk + 20 incremental updates (5 entities, 25k states)", async ({
bench,
}) => {
await bench(
"initial chunk + 20 incremental updates (5 entities, 25k states)",
() => {
const stream = new HistoryStream(hass, HOURS_TO_SHOW);
stream.processMessage(initialMessage);
for (const message of incrementalMessages) {
stream.processMessage(message);
}
bench(
"initial chunk + 20 incremental updates (5 entities, 25k states)",
() => {
const stream = new HistoryStream(hass, HOURS_TO_SHOW);
stream.processMessage(initialMessage);
for (const message of incrementalMessages) {
stream.processMessage(message);
}
).run({ time: 1000, warmupIterations: 2 });
});
},
{ time: 1000, warmupIterations: 2 }
);
});
+27 -23
View File
@@ -1,4 +1,4 @@
import { describe, test } from "vitest";
import { bench, describe } from "vitest";
import { computeHistory } from "../../src/data/history";
import {
generateMixedHistory,
@@ -22,35 +22,39 @@ for (let i = 0; i < 20; i++) {
}
describe("computeHistory", () => {
test("mixed medium (10k states)", async ({ bench }) => {
await bench("mixed medium (10k states)", () => {
computeHistory(hass, medium, [], mockLocalize);
}).run();
bench("mixed medium (10k states)", () => {
computeHistory(hass, medium, [], mockLocalize);
});
test("mixed large (100k states)", async ({ bench }) => {
await bench("mixed large (100k states)", () => {
bench(
"mixed large (100k states)",
() => {
computeHistory(hass, large, [], mockLocalize);
}).run({ time: 1000, warmupIterations: 2 });
});
},
{ time: 1000, warmupIterations: 2 }
);
test("mixed large with splitDeviceClasses (100k states)", async ({
bench,
}) => {
await bench("mixed large with splitDeviceClasses (100k states)", () => {
bench(
"mixed large with splitDeviceClasses (100k states)",
() => {
computeHistory(hass, large, [], mockLocalize, true);
}).run({ time: 1000, warmupIterations: 2 });
});
},
{ time: 1000, warmupIterations: 2 }
);
test("single dense sensor (100k states)", async ({ bench }) => {
await bench("single dense sensor (100k states)", () => {
bench(
"single dense sensor (100k states)",
() => {
computeHistory(hass, singleDense, [], mockLocalize);
}).run({ time: 1000, warmupIterations: 2 });
});
},
{ time: 1000, warmupIterations: 2 }
);
test("many entities (20 x 5k states)", async ({ bench }) => {
await bench("many entities (20 x 5k states)", () => {
bench(
"many entities (20 x 5k states)",
() => {
computeHistory(hass, manyEntities, [], mockLocalize);
}).run({ time: 1000, warmupIterations: 2 });
});
},
{ time: 1000, warmupIterations: 2 }
);
});
@@ -1,4 +1,4 @@
import { describe, test } from "vitest";
import { bench, describe } from "vitest";
import type {
DeviceConsumptionEnergyPreference,
EnergyData,
@@ -119,27 +119,27 @@ const run = (data: EnergyData) =>
});
describe("generateEnergyDevicesDetailGraphData", () => {
test("day of hourly data", async ({ bench }) => {
await bench("day of hourly data", () => {
run(dayHourly);
}).run();
bench("day of hourly data", () => {
run(dayHourly);
});
test("week of hourly data", async ({ bench }) => {
await bench("week of hourly data", () => {
run(weekHourly);
}).run();
bench("week of hourly data", () => {
run(weekHourly);
});
test("month of 5-minute data with compare", async ({ bench }) => {
await bench("month of 5-minute data with compare", () => {
bench(
"month of 5-minute data with compare",
() => {
run(monthFiveMinute);
}).run({ time: 1000, warmupIterations: 2 });
});
},
{ time: 1000, warmupIterations: 2 }
);
test("month of hourly data, 20 parents x 5 children", async ({ bench }) => {
await bench("month of hourly data, 20 parents x 5 children", () => {
bench(
"month of hourly data, 20 parents x 5 children",
() => {
run(hierarchyData as EnergyData);
}).run({ time: 1000, warmupIterations: 2 });
});
},
{ time: 1000, warmupIterations: 2 }
);
});
@@ -1,4 +1,4 @@
import { describe, test } from "vitest";
import { bench, describe } from "vitest";
import type { EnergyData } from "../../src/data/energy";
import { generatePowerSourcesGraphData } from "../../src/panels/lovelace/cards/energy/power-sources-graph-data";
import { createMockComputedStyle } from "../fixtures/computed-style";
@@ -122,41 +122,37 @@ const base = {
} as const;
describe("generatePowerSourcesGraphData", () => {
test("small (1 day hourly)", async ({ bench }) => {
await bench("small (1 day hourly)", () => {
// generatePowerSourcesGraphData pushes a synthetic point into the stats
// arrays when showing "today"; clone so iterations stay independent.
generatePowerSourcesGraphData({
...base,
energyData: { ...small, stats: structuredClone(small.stats) },
});
}).run();
bench("small (1 day hourly)", () => {
// generatePowerSourcesGraphData pushes a synthetic point into the stats
// arrays when showing "today"; clone so iterations stay independent.
generatePowerSourcesGraphData({
...base,
energyData: { ...small, stats: structuredClone(small.stats) },
});
});
test("medium (1 month hourly)", async ({ bench }) => {
await bench("medium (1 month hourly)", () => {
generatePowerSourcesGraphData({
...base,
energyData: { ...medium, stats: structuredClone(medium.stats) },
});
}).run();
bench("medium (1 month hourly)", () => {
generatePowerSourcesGraphData({
...base,
energyData: { ...medium, stats: structuredClone(medium.stats) },
});
});
test("large (2 weeks 5-minute)", async ({ bench }) => {
await bench("large (2 weeks 5-minute)", () => {
bench(
"large (2 weeks 5-minute)",
() => {
generatePowerSourcesGraphData({
...base,
energyData: { ...large, stats: structuredClone(large.stats) },
});
}).run({ time: 1000, warmupIterations: 2 });
});
},
{ time: 1000, warmupIterations: 2 }
);
test("many series (~18 series, 1 month hourly)", async ({ bench }) => {
await bench("many series (~18 series, 1 month hourly)", () => {
generatePowerSourcesGraphData({
...base,
energyData: { ...manySeries, stats: structuredClone(manySeries.stats) },
});
}).run();
bench("many series (~18 series, 1 month hourly)", () => {
generatePowerSourcesGraphData({
...base,
energyData: { ...manySeries, stats: structuredClone(manySeries.stats) },
});
});
});
@@ -1,4 +1,4 @@
import { describe, test } from "vitest";
import { bench, describe } from "vitest";
import { generateStateHistoryChartLineData } from "../../src/components/chart/state-history-chart-line-data";
import { computeHistory } from "../../src/data/history";
import { createMockComputedStyle } from "../fixtures/computed-style";
@@ -33,21 +33,23 @@ const base = {
} as const;
describe("generateStateHistoryChartLineData", () => {
test("mixed medium (10k states)", async ({ bench }) => {
await bench("mixed medium (10k states)", () => {
generateStateHistoryChartLineData({ ...base, data: medium });
}).run();
bench("mixed medium (10k states)", () => {
generateStateHistoryChartLineData({ ...base, data: medium });
});
test("mixed large (100k states)", async ({ bench }) => {
await bench("mixed large (100k states)", () => {
bench(
"mixed large (100k states)",
() => {
generateStateHistoryChartLineData({ ...base, data: large });
}).run({ time: 1000, warmupIterations: 2 });
});
},
{ time: 1000, warmupIterations: 2 }
);
test("climate entity (20k states)", async ({ bench }) => {
await bench("climate entity (20k states)", () => {
bench(
"climate entity (20k states)",
() => {
generateStateHistoryChartLineData({ ...base, data: climate });
}).run({ time: 1000, warmupIterations: 2 });
});
},
{ time: 1000, warmupIterations: 2 }
);
});
+19 -13
View File
@@ -1,4 +1,4 @@
import { describe, test } from "vitest";
import { bench, describe } from "vitest";
import { generateStatisticsChartData } from "../../src/components/chart/statistics-chart-data";
import { StatisticMeanType } from "../../src/data/recorder";
import type { StatisticsMetaData } from "../../src/data/recorder";
@@ -62,8 +62,9 @@ const base = {
} as const;
describe("generateStatisticsChartData", () => {
test("line with bands, hourly month, 5 entities", async ({ bench }) => {
await bench("line with bands, hourly month, 5 entities", () => {
bench(
"line with bands, hourly month, 5 entities",
() => {
generateStatisticsChartData({
...base,
statisticsData: meanMonth,
@@ -72,11 +73,13 @@ describe("generateStatisticsChartData", () => {
chartType: "line",
period: "hour",
});
}).run({ time: 1000, warmupIterations: 2 });
});
},
{ time: 1000, warmupIterations: 2 }
);
test("line with bands, 5-minute week, 5 entities", async ({ bench }) => {
await bench("line with bands, 5-minute week, 5 entities", () => {
bench(
"line with bands, 5-minute week, 5 entities",
() => {
generateStatisticsChartData({
...base,
statisticsData: meanWeek5min,
@@ -85,11 +88,13 @@ describe("generateStatisticsChartData", () => {
chartType: "line",
period: "5minute",
});
}).run({ time: 1000, warmupIterations: 2 });
});
},
{ time: 1000, warmupIterations: 2 }
);
test("stacked bars, hourly month sums, 5 entities", async ({ bench }) => {
await bench("stacked bars, hourly month sums, 5 entities", () => {
bench(
"stacked bars, hourly month sums, 5 entities",
() => {
generateStatisticsChartData({
...base,
statisticsData: sumMonth,
@@ -98,6 +103,7 @@ describe("generateStatisticsChartData", () => {
chartType: "bar-stack",
period: "hour",
});
}).run({ time: 1000, warmupIterations: 2 });
});
},
{ time: 1000, warmupIterations: 2 }
);
});
+7 -13
View File
@@ -1,4 +1,4 @@
import { describe, test } from "vitest";
import { bench, describe } from "vitest";
import {
computeHistory,
convertStatisticsToHistory,
@@ -43,23 +43,17 @@ idsFor(10).forEach((id, i) => {
const historyResult = computeHistory(hass, recentStates, [], mockLocalize);
describe("convertStatisticsToHistory", () => {
test("hourly statistics, month, 5 entities", async ({ bench }) => {
await bench("hourly statistics, month, 5 entities", () => {
convertStatisticsToHistory(hass, hourlyMonth, idsFor(5));
}).run();
bench("hourly statistics, month, 5 entities", () => {
convertStatisticsToHistory(hass, hourlyMonth, idsFor(5));
});
test("5-minute statistics, week, 5 entities", async ({ bench }) => {
await bench("5-minute statistics, week, 5 entities", () => {
convertStatisticsToHistory(hass, fiveMinuteWeek, idsFor(5));
}).run();
bench("5-minute statistics, week, 5 entities", () => {
convertStatisticsToHistory(hass, fiveMinuteWeek, idsFor(5));
});
});
describe("mergeHistoryResults", () => {
test("month of LTS + recent history, 10 entities", async ({ bench }) => {
await bench("month of LTS + recent history, 10 entities", () => {
mergeHistoryResults(historyResult, ltsResult);
}).run();
bench("month of LTS + recent history, 10 entities", () => {
mergeHistoryResults(historyResult, ltsResult);
});
});
@@ -8,12 +8,11 @@ const HASS_URL = `${location.protocol}//${location.host}`;
describe("token_storage.saveTokens", () => {
beforeEach(() => {
vi.stubGlobal("localStorage", new FallbackStorage());
window.localStorage = new FallbackStorage();
vi.stubGlobal("__HASS_URL__", HASS_URL);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
vi.resetAllMocks();
});

Some files were not shown because too many files have changed in this diff Show More