Compare commits

..
Author SHA1 Message Date
Aidan Timson d95dea4911 Extract a reusable entity table 2026-09-08 08:33:37 +01:00
Aidan Timson 4032ead018 Fix entity filter selection races 2026-09-08 08:33:37 +01:00
235 changed files with 5040 additions and 17113 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.
+1 -1
View File
@@ -1 +1 @@
24.21.0
24.20.0
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: [],
},
{
-6
View File
@@ -358,12 +358,6 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
return data;
}
if (msg.endpoint === "/ingress/panels") {
// The navigation picker waits for this collection before it enables
// itself, so it has to answer even when no add-on exposes a panel.
return { panels: {} };
}
if (msg.endpoint === "/store/reload") {
return null;
}
@@ -57,7 +57,6 @@ This component is based on the webawesome input component.
| invalid | Boolean | false | Marks the input as invalid. |
| inset-label | Boolean | false | Uses an inset label style where the label stays inside the input. |
| validation-message | String | "" | Custom validation message shown when the input is invalid. |
| input-id | String | - | Sets the native input's `id` and associated label `for` on the inner `wa-input`. Defaults to `"input"`. |
| pattern | String | - | A regular expression pattern to validate input against. |
| minlength | Number | - | The minimum length of input that will be considered valid. |
| maxlength | Number | - | The maximum length of input that will be considered valid. |
+1 -265
View File
@@ -21,8 +21,6 @@ import "../../../../src/components/ha-settings-row";
import type { BlueprintInput } from "../../../../src/data/blueprint";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
import { StatisticMeanType } from "../../../../src/data/recorder";
import type { SerialPort } from "../../../../src/data/usb";
import {
showDialog,
type ShowDialogParams,
@@ -295,72 +293,9 @@ const LABELS: LabelRegistryEntry[] = [
},
];
const serialPort = (port: Partial<SerialPort>): SerialPort => ({
device: "/dev/ttyUSB0",
resolved_device: null,
serial_number: null,
manufacturer: null,
description: null,
matching_integrations: [],
present: true,
...port,
});
// One port per section the picker groups by, relative to the "zha" domain the
// serial port selector below is given as its flow context
const SERIAL_PORTS: SerialPort[] = [
serialPort({
device:
"/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0",
description: "SkyConnect v1.0",
manufacturer: "Nabu Casa",
serial_number: "9e2adbd75b8beb119fe564a0f320645d",
vid: "10C4",
pid: "EA60",
matching_integrations: ["zha"],
}),
serialPort({
device: "esphome-hass://01JQ8Z5X9WQ0/?port_name=UART0",
}),
serialPort({
device: "socket://192.168.1.10:6638",
}),
serialPort({
device: "/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_AB0KVD1L-if00-port0",
description: "FT232R USB UART",
manufacturer: "FTDI",
serial_number: "AB0KVD1L",
vid: "0403",
pid: "6001",
}),
serialPort({
device: "/dev/ttyS0",
description: "ttyS0",
manufacturer: "Intel",
}),
serialPort({ device: "/dev/ttyAMA0" }),
serialPort({
device:
"/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0",
description: "CP2102 USB to UART Bridge Controller",
manufacturer: "Silicon Labs",
serial_number: "0001",
vid: "10C4",
pid: "EA60",
matching_integrations: ["matter"],
}),
];
const SCHEMAS: {
name: string;
input: Record<
string,
| (BlueprintInput & {
required?: boolean;
context?: Record<string, unknown>;
})
| null
>;
input: Record<string, (BlueprintInput & { required?: boolean }) | null>;
}[] = [
{
name: "One of each",
@@ -384,22 +319,7 @@ const SCHEMAS: {
selector: { config_entry: {} },
},
duration: { name: "Duration", selector: { duration: {} } },
signed_duration: {
name: "Signed duration",
selector: { duration: { mode: "signed" } },
},
offset_duration: {
name: "Offset",
selector: { duration: { mode: "offset", enable_day: true } },
},
app: { name: "App", selector: { app: {} } },
serial_port: {
name: "Serial port",
selector: { serial_port: {} },
// A config flow passes its own domain as context, which is what the
// picker groups recommended and not recommended ports by
context: { domain: "zha" },
},
number_box: {
name: "Number Box",
selector: {
@@ -584,99 +504,6 @@ const SCHEMAS: {
},
},
},
addon: { name: "Add-on", selector: { addon: {} } },
areas_display: {
name: "Areas display",
selector: { areas_display: {} },
},
assist_pipeline: {
name: "Assist pipeline",
selector: { assist_pipeline: {} },
},
automation_behavior: {
name: "Automation behavior",
selector: { automation_behavior: { mode: "trigger" } },
},
backup_location: {
name: "Backup location",
selector: { backup_location: {} },
},
button_toggle: {
name: "Button toggle",
selector: {
button_toggle: {
options: [
{ label: "Left", value: "left" },
{ label: "Center", value: "center" },
{ label: "Right", value: "right" },
],
},
},
},
condition: { name: "Condition", selector: { condition: {} } },
conversation_agent: {
name: "Conversation agent",
selector: { conversation_agent: {} },
},
country: {
name: "Country",
selector: { country: { countries: ["DE", "GB", "NL", "US"] } },
},
entity_name: {
name: "Entity name",
selector: { entity_name: { entity_id: "light.bedroom" } },
},
file: {
name: "File",
selector: { file: { accept: "image/png,image/jpeg" } },
},
language: { name: "Language", selector: { language: {} } },
navigation: { name: "Navigation", selector: { navigation: {} } },
numeric_threshold: {
name: "Numeric threshold",
selector: { numeric_threshold: {} },
},
period: {
name: "Period",
selector: {
period: {
options: ["today", "yesterday", "this_week", "this_month"],
},
},
// Without a value that matches one of the options the selector offers
// its custom-period editor instead of the list
default: { calendar: { period: "day" } },
},
selector: {
name: "Selector",
selector: { selector: {} },
default: { text: {} },
},
state_class: { name: "State class", selector: { state_class: {} } },
statistic: { name: "Statistic", selector: { statistic: {} } },
stt: { name: "Speech-to-text", selector: { stt: {} } },
theme: { name: "Theme", selector: { theme: {} } },
timezone: { name: "Time zone", selector: { timezone: {} } },
trigger: { name: "Trigger", selector: { trigger: {} } },
tts: { name: "Text-to-speech", selector: { tts: {} } },
tts_voice: {
name: "Text-to-speech voice",
selector: { tts_voice: { engineId: "tts.cloud", language: "en-US" } },
},
ui_action: { name: "UI action", selector: { ui_action: {} } },
ui_clock_date_format: {
name: "Clock date format",
selector: { ui_clock_date_format: {} },
},
ui_color: { name: "UI color", selector: { ui_color: {} } },
ui_state_content: {
name: "State content",
selector: { ui_state_content: { entity_id: "light.bedroom" } },
},
ui_time_format: {
name: "Time format",
selector: { ui_time_format: {} },
},
},
},
{
@@ -778,98 +605,8 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
mockFloorRegistry(hass, FLOORS);
mockLabelRegistry(hass, LABELS);
mockHassioSupervisor(hass);
hass.addTranslations({
"component.matter.title": "Matter",
"component.zha.title": "Zigbee Home Automation",
});
hass.updateHass({
config: {
...hass.config,
components: [...hass.config.components, "usb"],
},
});
hass.mockWS("auth/sign_path", (params) => params);
hass.mockWS("media_player/browse_media", this._browseMedia);
hass.mockWS("usb/list_serial_ports", () => SERIAL_PORTS);
hass.mockWS("recorder/list_statistic_ids", () => [
{
statistic_id: "sensor.energy_consumption",
statistics_unit_of_measurement: "kWh",
source: "recorder",
name: null,
has_sum: true,
mean_type: StatisticMeanType.NONE,
unit_class: "energy",
},
{
statistic_id: "sensor.outside_temperature",
statistics_unit_of_measurement: "\u00b0C",
source: "recorder",
name: null,
has_sum: false,
mean_type: StatisticMeanType.ARITHMETIC,
unit_class: "temperature",
},
]);
hass.mockWS("assist_pipeline/pipeline/list", () => ({
pipelines: [
{
id: "pipeline_home",
name: "Home Assistant",
language: "en",
conversation_engine: "conversation.home_assistant",
conversation_language: "en",
stt_engine: "stt.cloud",
stt_language: "en-US",
tts_engine: "tts.cloud",
tts_language: "en-US",
tts_voice: "JennyNeural",
wake_word_entity: null,
wake_word_id: null,
},
],
preferred_pipeline: "pipeline_home",
}));
hass.mockWS("conversation/agent/list", () => ({
agents: [
{
id: "conversation.home_assistant",
name: "Home Assistant",
supported_languages: "*",
},
{
id: "conversation.openai",
name: "OpenAI Conversation",
supported_languages: ["en"],
},
],
}));
hass.mockWS("stt/engine/list", () => ({
providers: [
{
engine_id: "stt.cloud",
name: "Home Assistant Cloud",
supported_languages: ["en-US"],
deprecated: false,
},
],
}));
hass.mockWS("tts/engine/list", () => ({
providers: [
{
engine_id: "tts.cloud",
name: "Home Assistant Cloud",
supported_languages: ["en-US"],
deprecated: false,
},
],
}));
hass.mockWS("tts/engine/voices", () => ({
voices: [
{ voice_id: "JennyNeural", name: "Jenny" },
{ voice_id: "GuyNeural", name: "Guy" },
],
}));
}
public provideHass(el) {
@@ -1039,7 +776,6 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
<ha-selector
.hass=${this.hass}
.selector=${value!.selector}
.context=${value!.context}
.key=${key}
.label=${this._label ? value!.name : undefined}
.value=${data[key] ?? value!.default}
+3 -4
View File
@@ -21,8 +21,7 @@ const ENTITIES = [
state: "on",
attributes: {
friendly_name: "Dining Room",
supported_color_modes: ["brightness"],
color_mode: "brightness",
supported_features: 1,
brightness: 100,
},
},
@@ -31,7 +30,7 @@ const ENTITIES = [
state: "off",
attributes: {
friendly_name: "Dining Room",
supported_color_modes: ["brightness"],
supported_features: 1,
},
},
{
@@ -39,7 +38,7 @@ const ENTITIES = [
state: "unavailable",
attributes: {
friendly_name: "Lost Light",
supported_color_modes: ["brightness"],
supported_features: 1,
},
},
];
@@ -11,7 +11,6 @@ import { LawnMowerEntityFeature } from "../../../../src/data/lawn_mower";
const ALL_FEATURES =
LawnMowerEntityFeature.START_MOWING +
LawnMowerEntityFeature.PAUSE +
LawnMowerEntityFeature.STOP +
LawnMowerEntityFeature.DOCK;
const ENTITIES = [
@@ -50,14 +49,6 @@ const ENTITIES = [
supported_features: ALL_FEATURES,
},
},
{
entity_id: "lawn_mower.idle",
state: "idle",
attributes: {
friendly_name: "Idle",
supported_features: ALL_FEATURES,
},
},
{
entity_id: "lawn_mower.error",
state: "error",
+12 -10
View File
@@ -67,7 +67,7 @@
"@fullcalendar/list": "6.1.21",
"@fullcalendar/luxon3": "6.1.21",
"@fullcalendar/timegrid": "6.1.21",
"@home-assistant/webawesome": "3.7.0-ha.1",
"@home-assistant/webawesome": "3.7.0-ha.0",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
@@ -84,6 +84,7 @@
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@vibrant/color": "4.0.4",
@@ -109,12 +110,13 @@
"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",
"luxon": "3.7.2",
"maplibre-gl": "5.24.0",
"marked": "18.0.12",
"marked": "18.0.11",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -152,7 +154,7 @@
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.3",
"@rspack/core": "2.2.3",
"@rspack/core": "2.2.2",
"@rspack/dev-server": "2.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -161,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",
@@ -169,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",
@@ -194,10 +197,10 @@
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "30.0.1",
"jszip": "3.10.2",
"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",
@@ -212,10 +215,9 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.70.0",
"vite": "8.2.2",
"typescript-eslint": "8.69.0",
"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"
@@ -231,6 +233,6 @@
},
"packageManager": "[email protected]",
"volta": {
"node": "24.21.0"
"node": "24.20.0"
}
}
-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 = [];
}
}
+21 -19
View File
@@ -9,34 +9,36 @@ export const createDurationData = (
}
if (typeof duration !== "object") {
if (typeof duration === "string" || isNaN(duration)) {
const durationString = duration.toString().trim();
// A leading "-" negates the whole period, matching
// `cv.time_period_str` in core.
const negative = durationString[0] === "-";
const parts = durationString
.split(":")
.map((part) =>
negative && part ? -Math.abs(Number(part)) : Number(part)
);
const parts = duration?.toString().split(":") || [];
if (parts.length === 1) {
return { seconds: parts[0] };
return { seconds: Number(parts[0]) };
}
if (parts.length > 3) {
return undefined;
}
const seconds = parts[2] || 0;
const secondsWhole = Math.trunc(seconds);
const seconds = Number(parts[2]) || 0;
const seconds_whole = Math.floor(seconds);
return {
hours: parts[0] || 0,
minutes: parts[1] || 0,
seconds: secondsWhole,
milliseconds: Math.trunc(
Number((seconds - secondsWhole).toFixed(4)) * 1000
hours: Number(parts[0]) || 0,
minutes: Number(parts[1]) || 0,
seconds: seconds_whole,
milliseconds: Math.floor(
Number((seconds - seconds_whole).toFixed(4)) * 1000
),
};
}
return { seconds: duration };
}
return duration;
if (!("days" in duration)) {
return duration;
}
const { days, minutes, seconds, milliseconds } = duration;
let hours = duration.hours || 0;
hours = (hours || 0) + (days || 0) * 24;
return {
hours,
minutes,
seconds,
milliseconds,
};
};
@@ -1,25 +0,0 @@
import type { HaDurationData } from "../../components/ha-duration-input";
export const durationValueToData = (
value?: HaDurationData | string | number
): HaDurationData | undefined => {
if (typeof value === "number") {
return value < 0
? { negative: true, seconds: Math.abs(value) }
: { seconds: value };
}
if (typeof value === "string") {
const negative = value.trim()[0] === "-";
const parts = value.split(":").map((p) => Math.abs(Number(p)));
let data: HaDurationData | undefined;
if (parts.length === 1) {
data = { seconds: parts[0] };
} else if (parts.length === 2) {
data = { hours: parts[0], minutes: parts[1] };
} else if (parts.length === 3) {
data = { hours: parts[0], minutes: parts[1], seconds: parts[2] };
}
return data && negative ? { negative: true, ...data } : data;
}
return value;
};
-30
View File
@@ -1,30 +0,0 @@
import type { HaDurationData } from "../../components/ha-duration-input";
import { durationDataToSeconds } from "./duration_to_seconds";
const COMPONENTS = [
"days",
"hours",
"minutes",
"seconds",
"milliseconds",
] as const;
export interface NormalizedDuration extends HaDurationData {
negative: boolean;
}
export const normalizeDuration = (
duration: HaDurationData
): NormalizedDuration => {
const { negative, ...components } = duration;
if (negative !== undefined) {
return { negative, ...components };
}
for (const field of COMPONENTS) {
const amount = components[field];
if (amount !== undefined) {
components[field] = Math.abs(amount);
}
}
return { negative: durationDataToSeconds(duration) < 0, ...components };
};
@@ -12,24 +12,13 @@ import { getEntityContext } from "./context/get_entity_context";
const DEFAULT_SEPARATOR = " ";
export const DEFAULT_ENTITY_NAME = [
{ type: "parent_device" },
{ type: "device" },
{ type: "entity" },
] satisfies EntityNameItem[];
export const ENTITY_NAME_TYPES = [
"floor",
"area",
"parent_device",
"device",
"entity",
] as const;
export type EntityNameType = (typeof ENTITY_NAME_TYPES)[number];
export type EntityNameItem =
| {
type: EntityNameType;
type: "entity" | "device" | "area" | "floor";
}
| {
type: "text";
@@ -102,7 +91,7 @@ export const computeEntityNameList = (
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
): (string | undefined)[] => {
const { device, parentDevice, area, floor } = getEntityContext(
const { device, area, floor } = getEntityContext(
stateObj,
entities,
devices,
@@ -116,8 +105,6 @@ export const computeEntityNameList = (
return computeEntityName(stateObj, entities, devices);
case "device":
return device ? computeDeviceName(device) : undefined;
case "parent_device":
return parentDevice ? computeDeviceName(parentDevice) : undefined;
case "area":
return area ? computeAreaName(area) : undefined;
case "floor":
@@ -149,20 +136,14 @@ export const computeEntityPickerDisplay = (
>,
stateObj: HassEntity
): EntityPickerDisplay => {
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const isRTL = computeRTL(
hass.language,
@@ -171,7 +152,7 @@ export const computeEntityPickerDisplay = (
const primary = entityName || deviceName || stateObj.entity_id;
const secondary =
[areaName, parentDeviceName, entityName ? deviceName : undefined]
[areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ") || undefined;
@@ -13,7 +13,6 @@ import { getDeviceAreaId } from "./get_device_context";
interface EntityContext {
entity: EntityRegistryDisplayEntry | null;
device: DeviceRegistryEntry | null;
parentDevice: DeviceRegistryEntry | null;
area: AreaRegistryEntry | null;
floor: FloorRegistryEntry | null;
}
@@ -32,7 +31,6 @@ export const getEntityContext = (
return {
entity: null,
device: null,
parentDevice: null,
area: null,
floor: null,
};
@@ -67,9 +65,6 @@ export const getEntityEntryContext = (
const entity = entities[entry.entity_id];
const deviceId = entry?.device_id;
const device = deviceId ? devices[deviceId] : undefined;
const parentDevice = device?.parent_device_id
? devices[device.parent_device_id]
: undefined;
const areaId =
entry?.area_id || (device ? getDeviceAreaId(device, devices) : undefined);
const area = areaId ? areas[areaId] : undefined;
@@ -79,7 +74,6 @@ export const getEntityEntryContext = (
return {
entity: entity,
device: device || null,
parentDevice: parentDevice || null,
area: area || null,
floor: floor || null,
};
+1 -1
View File
@@ -29,7 +29,7 @@ export const FIXED_DOMAIN_STATES = {
fan: ["on", "off"],
humidifier: ["on", "off"],
input_boolean: ["on", "off"],
lawn_mower: ["error", "paused", "mowing", "returning", "docked", "idle"],
lawn_mower: ["error", "paused", "mowing", "returning", "docked"],
light: ["on", "off"],
lock: [
"jammed",
+1 -1
View File
@@ -36,7 +36,7 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
case "person":
return compareState !== "not_home";
case "lawn_mower":
return !["docked", "paused", "idle"].includes(compareState);
return !["docked", "paused"].includes(compareState);
case "lock":
return compareState !== "locked";
case "media_player":
+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];
};
+2
View File
@@ -31,6 +31,8 @@ export type FormatEntityAttributeNameFunc = (
attribute: string
) => string;
export type EntityNameType = "entity" | "device" | "area" | "floor";
export type FormatEntityNameFunc = (
stateObj: HassEntity,
name: EntityNameItem | EntityNameItem[],
@@ -1,48 +0,0 @@
import {
DateFormat,
FirstWeekday,
NumberFormat,
TimeFormat,
TimeZone,
} from "../../data/translation";
import { translationMetadata } from "../../resources/translations-metadata";
import type { HomeAssistantInternationalization } from "../../types";
import type { LocalizeFunc } from "./localize";
/**
* Builds an internationalization context value for the "lite" localize mode
* used before a `hass` object exists. Locale falls back to browser defaults and
* the lazy loaders are stubs that warn when called.
*/
export const buildLiteInternationalization = (
language: string,
localize: LocalizeFunc
): HomeAssistantInternationalization => ({
language,
selectedLanguage: language,
locale: {
language,
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.local,
first_weekday: FirstWeekday.language,
},
localize,
translationMetadata,
loadBackendTranslation: async (category, integrations, configFlow) => {
// eslint-disable-next-line no-console
console.warn(
"loadBackendTranslation called in lite i18n mode; backend strings are not available",
{ category, integrations, configFlow }
);
return localize;
},
loadFragmentTranslation: async (fragment) => {
// eslint-disable-next-line no-console
console.warn(
`loadFragmentTranslation("${fragment}") called in lite i18n mode; fragment not loaded`
);
return undefined;
},
});
@@ -1,154 +0,0 @@
import { consume, type ContextType } from "@lit/context";
import { mdiCommentTextOutline } from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { truncateWithEllipsis } from "../../common/string/truncate-with-ellipsis";
import type { Condition } from "../../data/automation";
import type { ConditionDescription } from "../../data/condition";
import { internationalizationContext } from "../../data/context";
import "../../panels/config/automation/ha-automation-row-behavior";
import "../../panels/config/automation/ha-automation-row-options";
import { getDeviceTarget } from "../../panels/config/automation/target/get_device_target";
import { getEntityTarget } from "../../panels/config/automation/target/get_entity_target";
import "../../panels/config/automation/target/ha-automation-row-targets";
import "../ha-svg-icon";
import "../ha-tooltip";
@customElement("ha-automation-condition-summary")
export class HaAutomationConditionSummary extends LitElement {
@property() public label = "";
@property({ attribute: false }) public condition?: Condition;
@property({ attribute: false }) public description?: ConditionDescription;
@property({ attribute: false }) public isNew = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
private _getEntityTarget = memoizeOne(getEntityTarget);
private _getDeviceTarget = memoizeOne(getDeviceTarget);
protected render() {
const descriptionHasTarget = "target" in (this.description || {});
const hasEntityTarget =
this.condition?.condition === "state" ||
this.condition?.condition === "numeric_state";
const targetRequired =
(descriptionHasTarget || hasEntityTarget) && !this.isNew;
const note = this.condition?.note?.trim();
let target: HassServiceTarget | undefined;
if (this.condition) {
if (descriptionHasTarget && "target" in this.condition) {
target = this.condition.target;
} else if (
hasEntityTarget &&
"entity_id" in this.condition &&
this.condition.entity_id
) {
target = this._getEntityTarget(this.condition.entity_id);
} else if ("device_id" in this.condition && this.condition.device_id) {
target = this._getDeviceTarget(this.condition.device_id);
}
}
return html`
<h3>
${this.label}
${
this.description && this.condition
? html`<ha-automation-row-behavior
mode="condition"
.config=${this.condition}
></ha-automation-row-behavior>`
: nothing
}
${
target !== undefined || targetRequired
? html`<ha-automation-row-targets
.target=${target}
.targetRequired=${targetRequired}
.selector=${
this.description?.target
? { target: this.description.target }
: undefined
}
.interactive=${this.condition?.condition !== "device"}
></ha-automation-row-targets>`
: nothing
}
${
this.description && this.condition
? html`<ha-automation-row-options
.config=${this.condition}
></ha-automation-row-options>`
: nothing
}
${
note
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
role="img"
.path=${mdiCommentTextOutline}
aria-label=${this._i18n.localize(
"ui.panel.config.automation.editor.note.label"
)}
class="note-indicator"
></ha-svg-icon>
<ha-tooltip for="note-icon"
><p>${truncateWithEllipsis(note, 250)}</p></ha-tooltip
>
`
: nothing
}
</h3>
`;
}
static styles = css`
:host {
display: block;
min-width: 0;
}
h3 {
margin: 0;
font-size: inherit;
font-weight: inherit;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ha-space-2);
padding: var(--ha-space-2) 0;
min-height: 32px;
max-width: 100%;
}
.note-indicator {
color: var(--ha-color-on-neutral-normal);
}
ha-tooltip {
cursor: default;
}
ha-tooltip::part(body) {
cursor: default;
max-width: 300px;
}
ha-tooltip p {
white-space: pre-wrap;
margin: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-condition-summary": HaAutomationConditionSummary;
}
}
+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>();
@@ -23,8 +23,6 @@ import { measureTextWidth } from "../../util/text";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
const ROW_HEIGHT = 30;
// Taller rows when the name is drawn under the bar instead of in a column.
const ROW_HEIGHT_INSIDE_LABELS = 64;
const GRID_BOTTOM = 30;
@customElement("state-history-chart-timeline")
@@ -43,12 +41,6 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
// Render each row's name inside the plot (under its bar) instead of in a
// left-hand category-label column. Opt-in; used by the history panel and
// history-graph card.
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -78,13 +70,7 @@ export class StateHistoryChartTimeline extends LitElement {
<ha-chart-base
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${
this.data.length *
(this.insideLabels && (this.chunked || this.showNames)
? ROW_HEIGHT_INSIDE_LABELS
: ROW_HEIGHT) +
GRID_BOTTOM
}px`}
.height=${`${this.data.length * ROW_HEIGHT + GRID_BOTTOM}px`}
.data=${this._chartData as HaECSeries}
small-controls
@chart-click=${this._handleChartClick}
@@ -199,7 +185,6 @@ export class StateHistoryChartTimeline extends LitElement {
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("showNames") ||
changedProps.has("insideLabels") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth")
) {
@@ -211,11 +196,9 @@ export class StateHistoryChartTimeline extends LitElement {
const narrow = this.narrow;
const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 105 : 185;
const insideLabels = this.insideLabels;
const labelWidth =
showNames && !insideLabels
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelMargin = 5;
const rtl = computeRTL(
this.hass.language,
@@ -244,47 +227,31 @@ export class StateHistoryChartTimeline extends LitElement {
axisLine: {
show: false,
},
axisLabel: insideLabels
? {
// Draw the name inside the plot, under each row's bar, matching
// the line charts whose legend sits under the plot. The taller
// rows keep a name clear of the next row's bar.
show: showNames,
inside: true,
margin: 0,
padding: [18, 0, 0, rtl ? 0 : 2],
align: rtl ? "right" : "left",
verticalAlign: "top",
formatter: (id: string) =>
(this._chartData.find((d) => d.id === id)?.name as string) ??
"",
hideOverlap: true,
axisLabel: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
return label;
},
hideOverlap: true,
},
return label;
},
hideOverlap: true,
},
},
grid: {
top: 10,
@@ -79,11 +79,6 @@ export class StateHistoryCharts extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
// Render timeline row names inside the plot (under each bar) instead of in a
// left-hand column. Opt-in; used by the history panel and history-graph card.
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -232,7 +227,6 @@ export class StateHistoryCharts extends LitElement {
.startTime=${this._computedStartTime}
.endTime=${this._computedEndTime}
.showNames=${this.showNames}
.insideLabels=${this.insideLabels}
.names=${this.names}
.narrow=${this.narrow}
.chunked=${this.virtualize}
@@ -450,10 +444,6 @@ export class StateHistoryCharts extends LitElement {
margin-top: 16px;
}
.entry-container.timeline:not(:first-child) {
margin-top: var(--ha-space-8);
}
.container,
lit-virtualizer {
height: 100%;
-8
View File
@@ -35,14 +35,6 @@ export class HaFilterChip extends FilterChip {
`,
];
protected getContainerClasses() {
const classes = super.getContainerClasses();
if (this.noLeadingIcon) {
classes["has-icon"] = false;
}
return classes;
}
protected renderLeadingIcon() {
if (this.noLeadingIcon) {
// eslint-disable-next-line lit/prefer-nothing
+8 -11
View File
@@ -7,12 +7,9 @@ import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import {
ENTITY_NAME_TYPES,
type EntityNameItem,
type EntityNameType,
} from "../../common/entity/compute_entity_name_display";
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import type { EntityNameType } from "../../common/translations/entity-state";
import type { LocalizeKeys } from "../../common/translations/localize";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../chips/ha-assist-chip";
@@ -38,7 +35,9 @@ const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
</ha-combo-box-item>
`;
const KNOWN_TYPES = new Set<string>(ENTITY_NAME_TYPES);
const KNOWN_TYPES = new Set(["entity", "device", "area", "floor"]);
const UNIQUE_TYPES = new Set(["entity", "device", "area", "floor"]);
const formatOptionValue = (item: EntityNameItem) => {
if (item.type === "text" && item.text) {
@@ -171,7 +170,6 @@ export class HaEntityNamePicker extends LitElement {
.getItems=${this._getFilteredItems}
.rowRenderer=${rowRenderer}
.value=${this._getPickerValue()}
no-sort
allow-custom-value
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.custom_name"
@@ -389,7 +387,6 @@ export class HaEntityNamePicker extends LitElement {
);
if (context.device) options.add("device");
if (context.parentDevice) options.add("parent_device");
if (context.area) options.add("area");
if (context.floor) options.add("floor");
return options;
@@ -402,8 +399,8 @@ export class HaEntityNamePicker extends LitElement {
const types = this._validTypes(entityId);
const items = ENTITY_NAME_TYPES.filter(
(name) => name !== "parent_device" || types.has(name)
const items = (
["entity", "device", "area", "floor"] as const
).map<PickerComboBoxItem>((name) => {
const stateObj = this.hass.states[entityId];
const isValid = types.has(name);
@@ -467,7 +464,7 @@ export class HaEntityNamePicker extends LitElement {
const excludedValues = new Set(
this._items
.filter((item) => KNOWN_TYPES.has(item.type))
.filter((item) => UNIQUE_TYPES.has(item.type))
.map((item) => formatOptionValue(item))
);
@@ -178,17 +178,6 @@ export class HaStateContentPicker extends LitElement {
),
});
}
if (context.parentDevice) {
contextItems.push({
id: "parent_device_name",
primary: this.hass.localize(
"ui.components.state-content-picker.parent_device_name"
),
sorting_label: this.hass.localize(
"ui.components.state-content-picker.parent_device_name"
),
});
}
if (context.area) {
contextItems.push({
id: "area_name",
+18 -41
View File
@@ -52,7 +52,6 @@ const SEARCH_KEYS = [
{ name: "search_labels.entityName", weight: 10 },
{ name: "search_labels.friendlyName", weight: 9 },
{ name: "search_labels.deviceName", weight: 8 },
{ name: "search_labels.parentDeviceName", weight: 6 },
{ name: "search_labels.areaName", weight: 6 },
{ name: "search_labels.domainName", weight: 4 },
{ name: "statisticId", weight: 3 },
@@ -293,27 +292,17 @@ export class HaStatisticPicker extends LitElement {
const friendlyName = computeStateName(stateObj); // Keep this for search
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const primary = entityName || deviceName || id;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
@@ -329,7 +318,6 @@ export class HaStatisticPicker extends LitElement {
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
friendlyName,
},
@@ -407,20 +395,14 @@ export class HaStatisticPicker extends LitElement {
const stateObj = this.hass.states[statisticId];
if (stateObj) {
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const isRTL = computeRTL(
this.hass.language,
@@ -428,11 +410,7 @@ export class HaStatisticPicker extends LitElement {
);
const primary = entityName || deviceName || statisticId;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const friendlyName = computeStateName(stateObj); // Keep this for search
@@ -449,7 +427,6 @@ export class HaStatisticPicker extends LitElement {
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
friendlyName,
statisticId,
+3 -28
View File
@@ -50,11 +50,7 @@ export class HaAnalytics extends LitElement {
.preference=${"base"}
.disabled=${loading}
name="base"
>
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.title`
)}
</ha-switch>
></ha-switch>
</ha-row-item>
${ADDITIONAL_PREFERENCES.map(
(preference) => html`
@@ -76,11 +72,7 @@ export class HaAnalytics extends LitElement {
.checked=${!!this.analytics?.preferences[preference]}
.preference=${preference}
name=${preference}
>
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.title`
)}
</ha-switch>
></ha-switch>
${
baseEnabled
? nothing
@@ -114,11 +106,7 @@ export class HaAnalytics extends LitElement {
.preference=${"diagnostics"}
.disabled=${loading}
name="diagnostics"
>
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.title`
)}
</ha-switch>
></ha-switch>
</ha-row-item>
`;
}
@@ -155,19 +143,6 @@ export class HaAnalytics extends LitElement {
color: var(--error-color);
}
/* The visible headline already names the row. Keep the switch's
slotted label available to assistive technology without repeating it. */
ha-switch::part(label) {
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
border: 0;
}
ha-row-item {
--ha-row-item-padding-inline: 0;
}
+9 -19
View File
@@ -197,27 +197,17 @@ export class HaAreaControlsPicker extends LitElement {
return;
}
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass!.entities,
this.hass!.devices,
this.hass!.areas,
this.hass!.floors
);
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass!.entities,
this.hass!.devices,
this.hass!.areas,
this.hass!.floors
);
const primary = entityName || deviceName || entityId;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
+1 -120
View File
@@ -1,28 +1,18 @@
import { mdiClose, mdiMenuDown, mdiMinus, mdiPlus } from "@mdi/js";
import { mdiClose } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, queryAll } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import "./ha-dropdown";
import type { HaDropdownSelectEvent } from "./ha-dropdown";
import "./ha-dropdown-item";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./ha-input-helper-text";
import "./ha-select";
import type { HaSelectSelectEvent } from "./ha-select";
import "./input/ha-input";
import type { HaInput } from "./input/ha-input";
const SIGNS = [
{ negative: false, label: "+" },
{ negative: true, label: "" },
];
export interface TimeChangedEvent {
negative?: boolean;
days?: number;
hours: number;
minutes: number;
@@ -142,10 +132,6 @@ export class HaBaseTimeInput extends LitElement {
*/
@property({ attribute: false }) amPm: "AM" | "PM" = "AM";
@property({ attribute: "enable-sign", type: Boolean }) enableSign = false;
@property({ type: Boolean }) negative = false;
@property({ type: Boolean, reflect: true }) public clearable?: boolean;
@property({ attribute: "placeholder-labels", type: Boolean })
@@ -179,43 +165,6 @@ export class HaBaseTimeInput extends LitElement {
role="group"
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
>
${
this.enableSign
? html`<ha-dropdown
placement="bottom-start"
@wa-select=${this._signSelected}
@wa-after-hide=${stopPropagation}
@wa-hide=${stopPropagation}
>
<button
slot="trigger"
type="button"
class="sign"
aria-label=${this.negative ? "-" : "+"}
.disabled=${this.disabled}
>
<ha-svg-icon
.path=${this.negative ? mdiMinus : mdiPlus}
></ha-svg-icon>
<ha-svg-icon
class="chevron"
.path=${mdiMenuDown}
></ha-svg-icon>
</button>
${SIGNS.map(
({ negative, label }) => html`
<ha-dropdown-item
.value=${negative ? "-" : "+"}
.selected=${this.negative === negative}
>
${label}
</ha-dropdown-item>
`
)}
</ha-dropdown>
<div class="sign-divider"></div>`
: nothing
}
${
this.enableDay
? html`
@@ -375,35 +324,18 @@ export class HaBaseTimeInput extends LitElement {
fireEvent(this, "value-changed");
}
private _signSelected(ev: HaDropdownSelectEvent): void {
ev.stopPropagation();
const negative = ev.detail.item.value === "-";
if (negative === this.negative) {
return;
}
this.negative = negative;
this._fireValue();
}
private _valueChanged(ev: InputEvent | HaSelectSelectEvent): void {
const textField = ev.currentTarget as HaInput;
this[textField.name || ""] =
textField.name === "amPm"
? (ev as HaSelectSelectEvent).detail.value
: Number(textField.value);
this._fireValue();
}
private _fireValue(): void {
const value: TimeChangedEvent = {
hours: this.hours,
minutes: this.minutes,
seconds: this.seconds,
milliseconds: this.milliseconds,
};
if (this.enableSign) {
value.negative = this.negative;
}
if (this.enableDay) {
value.days = this.days;
}
@@ -479,10 +411,6 @@ export class HaBaseTimeInput extends LitElement {
padding-inline-start: var(--ha-space-4);
}
.sign-divider + ha-input::part(wa-base) {
padding-inline-start: var(--ha-space-2);
}
ha-input:last-child::part(wa-base) {
padding-inline-end: var(--ha-space-4);
}
@@ -495,53 +423,6 @@ export class HaBaseTimeInput extends LitElement {
text-align: center;
}
.sign {
display: flex;
align-items: center;
box-sizing: border-box;
height: 56px;
padding: 0;
padding-inline: var(--ha-space-3) var(--ha-space-1);
border: none;
border-bottom: 1px solid var(--ha-color-border-neutral-loud);
background-color: var(--ha-color-form-background);
color: var(--ha-color-text-secondary);
cursor: pointer;
--mdc-icon-size: 20px;
}
.sign .chevron {
--mdc-icon-size: 18px;
}
.sign:hover {
background-color: var(--ha-color-form-background-hover);
}
.sign:disabled {
cursor: default;
color: var(--ha-color-text-disabled);
}
.sign:focus-visible {
outline: 2px solid var(--ha-color-border-primary-normal);
outline-offset: -2px;
}
ha-dropdown-item {
font-size: var(--ha-font-size-l);
text-align: center;
}
.sign-divider {
display: flex;
align-items: center;
box-sizing: border-box;
height: 56px;
padding-inline: 0 var(--ha-space-1);
background-color: var(--ha-color-form-background);
border-bottom: 1px solid var(--ha-color-border-neutral-loud);
}
.sign-divider::after {
content: "";
width: 1px;
height: 24px;
background-color: var(--ha-color-border-neutral-quiet);
}
.time-separator,
ha-icon-button {
background-color: var(--ha-color-form-background);
-12
View File
@@ -761,9 +761,6 @@ export class HaCodeEditor extends ReactiveElement {
const deviceName = context.device
? computeDeviceName(context.device)
: undefined;
const parentDeviceName = context.parentDevice
? computeDeviceName(context.parentDevice)
: undefined;
const areaName = context.area ? computeAreaName(context.area) : undefined;
const floorName = context.floor
? computeFloorName(context.floor)
@@ -798,15 +795,6 @@ export class HaCodeEditor extends ReactiveElement {
});
}
if (parentDeviceName) {
completionItems.push({
label: this._i18n!.localize(
"ui.components.device-picker.parent_device"
),
value: parentDeviceName,
});
}
if (areaName) {
completionItems.push({
label: this._i18n!.localize("ui.components.area-picker.area"),
+116 -44
View File
@@ -1,14 +1,14 @@
import { mdiMinusThick, mdiPlusThick } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { normalizeDuration } from "../common/datetime/normalize_duration";
import { fireEvent } from "../common/dom/fire_event";
import type { ValueChangedEvent } from "../types";
import "./ha-base-time-input";
import type { HaBaseTimeInput, TimeChangedEvent } from "./ha-base-time-input";
import "./ha-button-toggle-group";
export interface HaDurationData {
negative?: boolean;
days?: number;
hours?: number;
minutes?: number;
@@ -44,6 +44,8 @@ export class HaDurationInput extends LitElement {
@query("ha-base-time-input", true) private _input?: HaBaseTimeInput;
private _toggleNegative = false;
static shadowRootOptions = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
@@ -54,11 +56,24 @@ export class HaDurationInput extends LitElement {
}
protected render(): TemplateResult {
const folded = this._data;
const data =
folded && this.allowNegative ? normalizeDuration(folded) : folded;
return html`
<div class="row">
${
this.allowNegative
? html`
<ha-button-toggle-group
size="s"
.buttons=${[
{ label: "+", iconPath: mdiPlusThick, value: "+" },
{ label: "-", iconPath: mdiMinusThick, value: "-" },
]}
.active=${this._negative ? "-" : "+"}
.disabled=${this.disabled}
@value-changed=${this._negativeChanged}
></ha-button-toggle-group>
`
: nothing
}
<ha-base-time-input
.label=${this.label}
.helper=${this.helper}
@@ -70,14 +85,12 @@ export class HaDurationInput extends LitElement {
.enableSecond=${this.enableSecond}
.enableMillisecond=${this.enableMillisecond}
.enableDay=${this.enableDay}
.enableSign=${this.allowNegative}
.negative=${!!data?.negative}
format="24"
.days=${this._component(data, "days")}
.hours=${this._component(data, "hours")}
.minutes=${this._component(data, "minutes")}
.seconds=${this._component(data, "seconds")}
.milliseconds=${this._component(data, "milliseconds")}
.days=${this._days}
.hours=${this._hours}
.minutes=${this._minutes}
.seconds=${this._seconds}
.milliseconds=${this._milliseconds}
@value-changed=${this._durationChanged}
no-hours-limit
day-label="dd"
@@ -90,40 +103,77 @@ export class HaDurationInput extends LitElement {
`;
}
// Fold units we do not render into the smallest unit we do, so a value the
// user cannot see is not silently dropped on the next edit.
private get _data(): HaDurationData | undefined {
if (!this.data) {
return this.data;
}
const data = { ...this.data };
if (!this.enableDay && data.days) {
data.hours = (data.hours || 0) + data.days * 24;
delete data.days;
}
if (!this.enableMillisecond && data.milliseconds) {
data.seconds = (data.seconds || 0) + data.milliseconds / 1000;
delete data.milliseconds;
}
return data;
private get _negative() {
return (
this._toggleNegative ||
(this.data?.days
? this.data.days < 0
: this.data?.hours
? this.data.hours < 0
: this.data?.minutes
? this.data.minutes < 0
: this.data?.seconds
? this.data.seconds < 0
: this.data?.milliseconds
? this.data.milliseconds < 0
: false)
);
}
private _component(
data: HaDurationData | undefined,
field: keyof HaDurationData
): number {
const amount = data?.[field];
if (amount) {
return Number(amount);
}
return this.required || data ? 0 : NaN;
private get _days() {
return this.data?.days
? this.allowNegative
? Math.abs(Number(this.data.days))
: Number(this.data.days)
: this.required || this.data
? 0
: NaN;
}
private get _hours() {
return this.data?.hours
? this.allowNegative
? Math.abs(Number(this.data.hours))
: Number(this.data.hours)
: this.required || this.data
? 0
: NaN;
}
private get _minutes() {
return this.data?.minutes
? this.allowNegative
? Math.abs(Number(this.data.minutes))
: Number(this.data.minutes)
: this.required || this.data
? 0
: NaN;
}
private get _seconds() {
return this.data?.seconds
? this.allowNegative
? Math.abs(Number(this.data.seconds))
: Number(this.data.seconds)
: this.required || this.data
? 0
: NaN;
}
private get _milliseconds() {
return this.data?.milliseconds
? this.allowNegative
? Math.abs(Number(this.data.milliseconds))
: Number(this.data.milliseconds)
: this.required || this.data
? 0
: NaN;
}
private _durationChanged(
ev: ValueChangedEvent<TimeChangedEvent | undefined>
) {
ev.stopPropagation();
const negative = ev.detail.value?.negative ?? false;
const value = ev.detail.value ? { ...ev.detail.value } : undefined;
if (value) {
@@ -167,17 +217,36 @@ export class HaDurationInput extends LitElement {
value.days = (value.days ?? 0) + Math.floor(value.hours / 24);
value.hours %= 24;
}
if (this._negative) {
FIELDS.forEach((t) => {
if (value[t]) {
value[t] = -Math.abs(value[t]);
}
});
}
}
fireEvent(this, "value-changed", {
value:
value && this.allowNegative ? this._withSign(value, negative) : value,
value,
});
}
private _withSign(value: HaDurationData, negative: boolean): HaDurationData {
const { negative: _negative, ...components } = normalizeDuration(value);
return negative ? { negative: true, ...components } : components;
private _negativeChanged(ev) {
ev.stopPropagation();
const negative = (ev.detail?.value || ev.target.value) === "-";
this._toggleNegative = negative;
if (this.data) {
const value = { ...this.data };
FIELDS.forEach((t) => {
if (value[t]) {
value[t] = negative ? -Math.abs(value[t]) : Math.abs(value[t]);
}
});
fireEvent(this, "value-changed", {
value,
});
}
}
static styles = css`
@@ -185,6 +254,9 @@ export class HaDurationInput extends LitElement {
display: flex;
align-items: center;
}
ha-button-toggle-group {
margin: var(--ha-space-2);
}
`;
}
+29 -24
View File
@@ -77,8 +77,9 @@ export class HaFilterDevices extends LitElement {
super.willUpdate(properties);
if (
properties.has("value") &&
!deepEqual(this.value, properties.get("value"))
properties.has("type") ||
(properties.has("value") &&
!deepEqual(this.value, properties.get("value")))
) {
this._findRelated();
}
@@ -124,7 +125,7 @@ export class HaFilterDevices extends LitElement {
this._states,
this._i18n.locale.language
)}
.rowRenderer=${this._renderItem}
.rowRenderer=${this._renderItem(this.value)}
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
></ha-list-selectable-virtualized>
@@ -134,18 +135,20 @@ export class HaFilterDevices extends LitElement {
`;
}
private _renderItem = (item?: HaFilterDevicesItem) =>
!item
? nothing
: html`<ha-list-item-option
style="width: 100%;"
appearance="checkbox"
selection-position="end"
.value=${item.id}
.selected=${this.value?.includes(item.id) ?? false}
>
<span slot="headline">${item.name}</span>
</ha-list-item-option>`;
private _renderItem = memoizeOne(
(value: string[] | undefined) => (item?: HaFilterDevicesItem) =>
!item
? nothing
: html`<ha-list-item-option
style="width: 100%;"
appearance="checkbox"
selection-position="end"
.value=${item.id}
.selected=${value?.includes(item.id) ?? false}
>
<span slot="headline">${item.name}</span>
</ha-list-item-option>`
);
private _handleAdded(ev: CustomEvent<number>) {
this.value = [
@@ -211,9 +214,11 @@ export class HaFilterDevices extends LitElement {
);
private async _findRelated() {
const value = this.value;
const type = this.type;
const relatedPromises: Promise<RelatedResult>[] = [];
if (!this.value?.length) {
if (!value?.length) {
this.value = [];
fireEvent(this, "data-table-filter-changed", {
value: [],
@@ -222,25 +227,25 @@ export class HaFilterDevices extends LitElement {
return;
}
const value: string[] = [];
for (const deviceId of this.value) {
value.push(deviceId);
if (this.type) {
for (const deviceId of value) {
if (type) {
relatedPromises.push(findRelated(this._api, "device", deviceId));
}
}
const results = await Promise.all(relatedPromises);
if (!deepEqual(value, this.value) || type !== this.type) {
return;
}
const items = new Set<string>();
for (const result of results) {
if (result[this.type!]) {
result[this.type!]!.forEach((item) => items.add(item));
if (type && result[type]) {
result[type].forEach((item) => items.add(item));
}
}
fireEvent(this, "data-table-filter-changed", {
value,
items: this.type ? items : undefined,
items: type ? items : undefined,
});
}
+19 -16
View File
@@ -79,8 +79,9 @@ export class HaFilterFloorAreas extends LitElement {
super.willUpdate(properties);
if (
properties.has("value") &&
!deepEqual(this.value, properties.get("value"))
properties.has("type") ||
(properties.has("value") &&
!deepEqual(this.value, properties.get("value")))
) {
this._findRelated();
}
@@ -278,12 +279,11 @@ export class HaFilterFloorAreas extends LitElement {
);
private async _findRelated() {
const value = this.value;
const type = this.type;
const relatedPromises: Promise<RelatedResult>[] = [];
if (
!this.value ||
(!this.value.areas?.length && !this.value.floors?.length)
) {
if (!value || (!value.areas?.length && !value.floors?.length)) {
this.value = {};
fireEvent(this, "data-table-filter-changed", {
value: {},
@@ -292,33 +292,36 @@ export class HaFilterFloorAreas extends LitElement {
return;
}
if (this.value.areas) {
for (const areaId of this.value.areas) {
if (this.type) {
if (value.areas) {
for (const areaId of value.areas) {
if (type) {
relatedPromises.push(findRelated(this._api, "area", areaId));
}
}
}
if (this.value.floors) {
for (const floorId of this.value.floors) {
if (this.type) {
if (value.floors) {
for (const floorId of value.floors) {
if (type) {
relatedPromises.push(findRelated(this._api, "floor", floorId));
}
}
}
const results = await Promise.all(relatedPromises);
if (!deepEqual(value, this.value) || type !== this.type) {
return;
}
const items = new Set<string>();
for (const result of results) {
if (result[this.type!]) {
result[this.type!]!.forEach((item) => items.add(item));
if (type && result[type]) {
result[type].forEach((item) => items.add(item));
}
}
fireEvent(this, "data-table-filter-changed", {
value: this.value,
items: this.type ? items : undefined,
value,
items: type ? items : undefined,
});
}
-1
View File
@@ -57,7 +57,6 @@ export class HaFormString extends LitElement implements HaFormElement {
.name=${this.schema.name}
.autofocus=${!!this.schema.autofocus}
.autocomplete=${this.schema.autocomplete}
.inputId=${this.schema.name}
.validationMessage=${
this.schema.required
? this.localize?.("ui.common.error_required")
@@ -28,11 +28,6 @@ const LAWN_MOWER_ACTIONS: Partial<
service: "start_mowing",
feature: LawnMowerEntityFeature.START_MOWING,
},
idle: {
action: "start_mowing",
service: "start_mowing",
feature: LawnMowerEntityFeature.START_MOWING,
},
returning: {
action: "pause",
service: "pause",
+1 -1
View File
@@ -145,7 +145,7 @@ export class HaSelect extends LitElement {
type="button"
class=${this._opened ? "opened" : ""}
compact
aria-label=${ifDefined(this.ariaLabel || this.label)}
aria-label=${ifDefined(this.label)}
@clear=${this._clearValue}
.label=${this.label}
.value=${valueLabel}
@@ -45,11 +45,8 @@ export class HaChooseSelector extends LitElement {
}
if (
changedProperties.has("value") &&
typeof this.value === "object" &&
this.value !== null &&
"active_choice" in this.value &&
this.value.active_choice in this.selector.choose.choices &&
this.value.active_choice !== this._activeChoice
changedProperties.get("value")?.active_choice &&
changedProperties.get("value")?.active_choice !== this._activeChoice
) {
this._setActiveChoice();
}
@@ -153,14 +150,14 @@ export class HaChooseSelector extends LitElement {
if (this.value === null || this.value === undefined) {
return undefined;
}
return typeof this.value === "object" && "active_choice" in this.value
return typeof this.value === "object"
? this.value[choice || this.value.active_choice]
: this.value;
}
private _setActiveChoice() {
if (this.value) {
if (typeof this.value === "object" && "active_choice" in this.value) {
if (typeof this.value === "object") {
if (this.value.active_choice in this.selector.choose.choices) {
this._activeChoice = this.value.active_choice;
return;
@@ -202,22 +199,6 @@ export class HaChooseSelector extends LitElement {
];
return;
}
if (
typeofValue === "object" &&
!Array.isArray(this.value) &&
Object.keys(this.value).length > 0 &&
Object.keys(this.value).every((key) =>
["days", "hours", "minutes", "seconds", "milliseconds"].includes(
key
)
) &&
selectorTypes.includes("duration")
) {
this._activeChoice = Object.keys(this.selector.choose.choices)[
selectorTypes.indexOf("duration")
];
return;
}
}
}
this._activeChoice = Object.keys(this.selector.choose.choices)[0];
@@ -1,34 +1,9 @@
import {
mdiClockMinusOutline,
mdiClockOutline,
mdiClockPlusOutline,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { durationDataToSeconds } from "../../common/datetime/duration_to_seconds";
import { normalizeDuration } from "../../common/datetime/normalize_duration";
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { DurationSelector } from "../../data/selector";
import { getDurationSelectorMode } from "../../data/selector";
import type { ValueChangedEvent } from "../../types";
import "../ha-duration-input";
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
import "../ha-input-helper-text";
import "../ha-select";
import type { HaSelectSelectEvent } from "../ha-select";
type OffsetType = "none" | "before" | "after";
const OFFSET_TYPES: { value: OffsetType; iconPath: string }[] = [
{ value: "none", iconPath: mdiClockOutline },
{ value: "before", iconPath: mdiClockMinusOutline },
{ value: "after", iconPath: mdiClockPlusOutline },
];
@customElement("ha-selector-duration")
export class HaTimeDuration extends LitElement {
@@ -45,207 +20,57 @@ export class HaTimeDuration extends LitElement {
@property({ type: Boolean }) public required = true;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@query("ha-duration-input") private _input?: HaDurationInput;
@state() private _offsetType?: OffsetType;
@query("ha-duration-input", true) private _input?: HaDurationInput;
public reportValidity(): boolean {
return this._input?.reportValidity() ?? true;
}
private _data = memoizeOne(durationValueToData);
private _data = memoizeOne(
(value?: HaDurationData | string | number): HaDurationData | undefined => {
if (typeof value === "number") {
return { seconds: value };
}
if (typeof value === "string") {
const negative = value.trim()[0] === "-";
const parts = value
.split(":")
.map((p) => (negative && p ? -Math.abs(Number(p)) : Number(p)));
private _offsetTypeOptions = memoizeOne((localize: LocalizeFunc) =>
OFFSET_TYPES.map(({ value, iconPath }) => ({
value,
iconPath,
label: localize(`ui.components.selectors.duration.offset.${value}`),
}))
if (parts.length === 1) {
return { seconds: parts[0] };
}
if (parts.length === 2) {
return { hours: parts[0], minutes: parts[1] };
}
if (parts.length === 3) {
return {
hours: parts[0],
minutes: parts[1],
seconds: parts[2],
};
}
return undefined;
}
return value;
}
);
protected render() {
const mode = getDurationSelectorMode(this.selector.duration);
const data = this._data(this.value);
if (mode !== "offset") {
return this._renderInput(
data,
this.label,
this.helper,
mode === "signed"
);
}
const offsetType = this._getOffsetType(data);
return html`
<div class="container">
${
this.label
? html`<label id="label"
>${this.label}${this.required ? "*" : ""}</label
>`
: nothing
}
<div
class="inputs"
role="group"
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
>
<ha-select
aria-label=${ifDefined(this.label)}
.value=${offsetType}
.options=${this._offsetTypeOptions(this._localize)}
.disabled=${this.disabled}
@selected=${this._offsetTypeChanged}
></ha-select>
${
offsetType === "none"
? nothing
: html`<div
class="value-row"
role="group"
aria-labelledby="duration-label"
>
<span id="duration-label" class="value-label"
>${this._localize(
"ui.components.selectors.duration.duration"
)}${this.required ? "*" : ""}</span
>
${this._renderInput(
data && this._components(data),
undefined
)}
</div>`
}
</div>
${
this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
</div>
`;
}
private _renderInput(
data: HaDurationData | undefined,
label: string | undefined,
helper?: string,
allowNegative = false
) {
return html`
<ha-duration-input
.label=${label}
.helper=${helper}
.data=${data}
.label=${this.label}
.helper=${this.helper}
.data=${this._data(this.value)}
.disabled=${this.disabled}
.required=${this.required}
.enableDay=${this.selector.duration?.enable_day}
.enableMillisecond=${this.selector.duration?.enable_millisecond}
.allowNegative=${allowNegative}
.allowNegative=${this.selector.duration?.allow_negative}
.enableSecond=${this.selector.duration?.enable_second ?? true}
@value-changed=${this._durationChanged}
></ha-duration-input>
`;
}
private _getOffsetType(data?: HaDurationData): OffsetType {
if (!data) {
return this._offsetType ?? "none";
}
const { negative, ...components } = normalizeDuration(data);
if (durationDataToSeconds(components) === 0) {
return this._offsetType ?? "none";
}
return negative ? "before" : "after";
}
private _components(data: HaDurationData): HaDurationData {
const { negative: _negative, ...components } = normalizeDuration(data);
return components;
}
private _zeroDuration(): HaDurationData {
const config = this.selector.duration;
const value: HaDurationData = { hours: 0, minutes: 0 };
if (config?.enable_day) value.days = 0;
if (config?.enable_second ?? true) value.seconds = 0;
if (config?.enable_millisecond) value.milliseconds = 0;
return value;
}
private _withOffsetType(
type: OffsetType,
data?: HaDurationData
): HaDurationData {
if (type === "none") {
return this._zeroDuration();
}
const components = this._components(data ?? this._zeroDuration());
return type === "before" ? { negative: true, ...components } : components;
}
private _durationChanged(ev: ValueChangedEvent<HaDurationData | undefined>) {
if (getDurationSelectorMode(this.selector.duration) !== "offset") {
return;
}
ev.stopPropagation();
const type = this._getOffsetType(this._data(this.value));
this._offsetType = type;
fireEvent(this, "value-changed", {
value: this._withOffsetType(type, ev.detail.value),
});
}
private _offsetTypeChanged(ev: HaSelectSelectEvent<OffsetType>) {
ev.stopPropagation();
const type = ev.detail.value;
const data = this._data(this.value);
if (!type || type === this._getOffsetType(data)) {
return;
}
this._offsetType = type;
fireEvent(this, "value-changed", {
value: this._withOffsetType(type, data),
});
}
static styles = css`
.container {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
label {
display: block;
font-size: var(--ha-font-size-s);
line-height: var(--ha-line-height-condensed);
color: var(--ha-color-text-primary);
padding-inline-start: var(--ha-space-1);
}
.inputs,
.value-row {
--ha-input-padding-bottom: 0;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.value-label {
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
ha-select {
width: 100%;
}
`;
}
declare global {
@@ -72,13 +72,8 @@ const SELECTOR_SCHEMAS = {
selector: { boolean: {} },
},
{
name: "mode",
selector: {
select: {
mode: "dropdown",
options: ["positive", "signed", "offset"],
},
},
name: "allow_negative",
selector: { boolean: {} },
},
] as const,
entity: [
@@ -258,13 +253,6 @@ export class HaSelectorSelector extends LitElement {
type,
...(typeof value0 === "object" ? value0 : []),
};
if (
type === "duration" &&
data.mode === undefined &&
data.allow_negative
) {
data.mode = "signed";
}
}
const schema = this._schema(type, this._localize!);
@@ -305,9 +293,6 @@ export class HaSelectorSelector extends LitElement {
this._yamlMode = false;
}
delete value.type;
if (type === "duration" && value.mode !== undefined) {
delete value.allow_negative;
}
let newValue;
if (type === "manual") {
-48
View File
@@ -77,7 +77,6 @@ export type InputType =
* @attr {boolean} invalid - Marks the input as invalid.
* @attr {boolean} inset-label - Uses an inset label style where the label stays inside the input.
* @attr {string} validation-message - Custom validation message shown when the input is invalid.
* @attr {string} input-id - Sets the native input's `id` (and associated label `for`) on the inner `wa-input`.
*/
@customElement("ha-input")
export class HaInput extends WaInputMixin(LitElement) {
@@ -125,10 +124,6 @@ export class HaInput extends WaInputMixin(LitElement) {
@property({ type: Boolean, attribute: "inset-label" })
public insetLabel = false;
/** Sets the native input id (and label `for`) on the inner `wa-input`. */
@property({ attribute: "input-id" })
public inputId?: string;
@query("wa-input")
private _input?: WaInput;
@@ -165,39 +160,6 @@ export class HaInput extends WaInputMixin(LitElement) {
this._input?.stepDown();
}
/**
* Copies a native input value that was written without `input`/`change` events
* (password-manager autofill) into the component so labels and form data update.
*/
public syncFromNativeInput(): boolean {
const native = this._getNativeInput();
if (!native) {
return false;
}
const nativeValue = native.value;
if ((this.value ?? "") === nativeValue) {
return false;
}
if (this._input) {
this._input.value = nativeValue;
}
this._handleInput();
this.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
return true;
}
public override reportValidity(): boolean {
this.syncFromNativeInput();
return super.reportValidity();
}
public override connectedCallback(): void {
super.connectedCallback();
this.addEventListener("focusin", this._syncFromNativeInput);
}
protected override async firstUpdated(
changedProperties: PropertyValues<this>
): Promise<void> {
@@ -213,7 +175,6 @@ export class HaInput extends WaInputMixin(LitElement) {
public override disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener("focusin", this._syncFromNativeInput);
this._startSlotResizeObserver?.disconnect();
}
@@ -254,7 +215,6 @@ export class HaInput extends WaInputMixin(LitElement) {
.inputmode=${this.inputmode || undefined}
.name=${this.name}
.disabled=${this.disabled}
.inputId=${this.inputId || "input"}
class=${classMap({
input: true,
invalid: this.invalid || this._invalid,
@@ -386,14 +346,6 @@ export class HaInput extends WaInputMixin(LitElement) {
this.passwordVisible = !this.passwordVisible;
}
private _getNativeInput(): HTMLInputElement | undefined {
return this._input?.input ?? undefined;
}
private _syncFromNativeInput = (): void => {
this.syncFromNativeInput();
};
static styles = [
waInputStyles,
css`
+8 -1
View File
@@ -192,7 +192,14 @@ export class HaListVirtualized extends HaListBase {
this.rangeStart = ev.first;
this.rangeEnd = ev.last;
await this.virtualizerElement?.layoutComplete;
try {
await this.virtualizerElement?.layoutComplete;
} catch (err) {
if (err === "disconnected") {
return;
}
throw err;
}
this._applySetSize();
if (!this.virtualizerElement) {
-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
@@ -129,31 +129,21 @@ export class HaMediaPlayerPicker extends LitElement {
.filter(this._filterPlayerEntities)
.map<MediaPlayerComboBoxItem>((stateObj) => {
const friendlyName = computeStateName(stateObj);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this._entities,
this._devices,
this._areas,
this._floors
);
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this._entities,
this._devices,
this._areas,
this._floors
);
const entityId = stateObj.entity_id;
const domainName = domainToName(
this._i18n.localize,
computeDomain(entityId)
);
const primary = entityName || deviceName || entityId;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
@@ -167,7 +157,6 @@ export class HaMediaPlayerPicker extends LitElement {
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
domainName: domainName || null,
friendlyName: friendlyName || null,
@@ -62,27 +62,17 @@ class HaMediaPlayerToggle extends LitElement {
isRTL: boolean,
stateObj: HassEntity
) => {
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
entities,
devices,
areas,
floors
);
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
entities,
devices,
areas,
floors
);
const primary = entityName || deviceName || entityId;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
@@ -1,181 +0,0 @@
import { getDeviceAreaId } from "../../common/entity/context/get_device_context";
import type {
ExtractFromTargetResultReferenced,
TargetType,
} from "../../data/target";
import type { HomeAssistant } from "../../types";
export interface TargetSubRows {
nextType: TargetType;
rows: string[];
rowEntries?: ExtractFromTargetResultReferenced[];
deviceRows: string[];
deviceRowEntries?: ExtractFromTargetResultReferenced[];
entityRows: string[];
}
const emptyEntries = (): ExtractFromTargetResultReferenced => ({
referenced_areas: [],
referenced_devices: [],
referenced_entities: [],
});
export const computeTargetSubRows = (
type: TargetType,
itemId: string,
entries: ExtractFromTargetResultReferenced,
entityRegistry: HomeAssistant["entities"],
devices: HomeAssistant["devices"]
): TargetSubRows => {
let nextType: TargetType =
type === "floor" ? "area" : type === "area" ? "device" : "entity";
if (type === "label") {
if (entries.referenced_areas.length) {
nextType = "area";
} else if (entries.referenced_devices.length) {
nextType = "device";
}
}
const deviceOf = (entityId: string) => entityRegistry[entityId]?.device_id;
const parentOf = (deviceId: string) => devices[deviceId]?.parent_device_id;
// An entity belongs to a device row when it is on that device or on one of
// its child devices, so the row can nest the children below it.
const belongsTo = (entityId: string, deviceId: string) => {
const entityDevice = deviceOf(entityId);
return (
entityDevice === deviceId ||
(!!entityDevice && parentOf(entityDevice) === deviceId)
);
};
const entitiesOf = (deviceId: string) =>
entries.referenced_entities.filter((entityId) =>
belongsTo(entityId, deviceId)
);
const rootsOf = (deviceIds: string[]) =>
deviceIds.filter((deviceId) => {
const parentId = parentOf(deviceId);
return !parentId || !deviceIds.includes(parentId);
});
const childDevices =
type === "device"
? [
...new Set(
entries.referenced_entities
.map(deviceOf)
.filter(
(deviceId): deviceId is string =>
!!deviceId &&
deviceId !== itemId &&
parentOf(deviceId) === itemId
)
),
]
: [];
const rows =
nextType === "area"
? entries.referenced_areas
: nextType === "device" && type !== "label"
? rootsOf(entries.referenced_devices)
: type === "device"
? entries.referenced_entities.filter(
(entityId) => !childDevices.includes(deviceOf(entityId) || "")
)
: type !== "label"
? entries.referenced_entities
: [];
const devicesInAreas: string[] = [];
const rowEntries =
nextType === "entity"
? undefined
: rows.map((rowItem) => {
const nextEntries = emptyEntries();
if (nextType === "area") {
const areaDevices = entries.referenced_devices.filter(
(deviceId) => {
const device = devices[deviceId];
return (
!!device &&
getDeviceAreaId(device, devices) === rowItem &&
entries.referenced_entities.some((entityId) =>
belongsTo(entityId, deviceId)
)
);
}
);
devicesInAreas.push(...areaDevices);
nextEntries.referenced_devices = rootsOf(areaDevices);
nextEntries.referenced_entities =
entries.referenced_entities.filter((entityId) => {
const entity = entityRegistry[entityId];
if (!entity) {
return false;
}
return (
entity.area_id === rowItem ||
!entity.device_id ||
areaDevices.includes(entity.device_id)
);
});
return nextEntries;
}
nextEntries.referenced_entities = entitiesOf(rowItem);
return nextEntries;
});
const entityRows =
type === "label"
? entries.referenced_entities.filter((entityId) => {
const entity = entityRegistry[entityId];
if (!entity) {
return false;
}
return (
entity.labels.includes(itemId) &&
!entries.referenced_devices.includes(entity.device_id || "")
);
})
: nextType === "device"
? entries.referenced_entities.filter(
(entityId) => entityRegistry[entityId]?.area_id === itemId
)
: [];
const deviceRows =
type === "label"
? rootsOf(
entries.referenced_devices.filter(
(deviceId) =>
!devicesInAreas.includes(deviceId) &&
devices[deviceId]?.labels.includes(itemId)
)
)
: childDevices;
const deviceRowEntries = deviceRows.length
? deviceRows.map((deviceId) => ({
...emptyEntries(),
referenced_entities: entitiesOf(deviceId),
}))
: undefined;
return {
nextType,
rows,
rowEntries,
deviceRows,
deviceRowEntries,
entityRows,
};
};
@@ -21,6 +21,7 @@ import {
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import {
@@ -29,10 +30,6 @@ import {
} from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import {
getDeviceArea,
getDeviceAreaId,
} from "../../common/entity/context/get_device_context";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { computeRTL } from "../../common/util/compute_rtl";
import type { AreaRegistryEntry } from "../../data/area/area_registry";
@@ -66,7 +63,6 @@ import "../ha-state-icon";
import "../ha-svg-icon";
import "../item/ha-list-item-base";
import "../item/ha-list-item-button";
import { computeTargetSubRows } from "./compute-target-sub-rows";
import { showTargetDetailsDialog } from "./dialog/show-dialog-target-details";
@customElement("ha-target-picker-item-row")
@@ -136,35 +132,10 @@ export class HaTargetPickerItemRow extends LitElement {
@consume({ context: labelsContext, subscribe: true })
_labelRegistry!: LabelRegistryEntry[];
private _loadedConfigEntryId?: string;
protected willUpdate(changedProps: PropertyValues<this>) {
if (!this.subEntry && changedProps.has("itemId")) {
this._updateItemData();
}
if (
changedProps.has("itemId") ||
changedProps.has("type") ||
changedProps.has("hass")
) {
this._updateDomain();
}
}
private _updateDomain() {
if (this.type === "entity") {
this._setDomainName(computeDomain(this.itemId));
return;
}
if (this.type !== "device") {
return;
}
const configEntryId =
this.hass.devices?.[this.itemId]?.primary_config_entry;
if (configEntryId && configEntryId !== this._loadedConfigEntryId) {
this._loadedConfigEntryId = configEntryId;
this._getDeviceDomain(configEntryId);
}
}
protected render() {
@@ -405,25 +376,125 @@ export class HaTargetPickerItemRow extends LitElement {
return this._renderEmptyEntries();
}
const {
nextType,
rows,
rowEntries,
deviceRows,
deviceRowEntries,
entityRows,
} = computeTargetSubRows(
this.type,
this.itemId,
entries,
this.hass.entities,
this.hass.devices
);
let nextType: TargetType =
this.type === "floor"
? "area"
: this.type === "area"
? "device"
: "entity";
if (this.type === "label") {
if (entries?.referenced_areas.length) {
nextType = "area";
} else if (entries?.referenced_devices.length) {
nextType = "device";
}
}
const rows1 =
(nextType === "area"
? entries?.referenced_areas
: nextType === "device" && this.type !== "label"
? entries?.referenced_devices
: this.type !== "label"
? entries?.referenced_entities
: []) || [];
const devicesInAreas = [] as string[];
const rows1Entries =
nextType === "entity"
? undefined
: rows1.map((rowItem) => {
const nextEntries = {
referenced_areas: [] as string[],
referenced_devices: [] as string[],
referenced_entities: [] as string[],
};
if (nextType === "area") {
nextEntries.referenced_devices =
entries?.referenced_devices.filter(
(device_id) =>
this.hass.devices?.[device_id]?.area_id === rowItem &&
entries?.referenced_entities.some(
(entity_id) =>
this.hass.entities?.[entity_id]?.device_id === device_id
)
) || ([] as string[]);
devicesInAreas.push(...nextEntries.referenced_devices);
nextEntries.referenced_entities =
entries?.referenced_entities.filter((entity_id) => {
const entity = this.hass.entities[entity_id];
if (!entity) {
return false;
}
return (
entity.area_id === rowItem ||
!entity.device_id ||
nextEntries.referenced_devices.includes(entity.device_id)
);
}) || ([] as string[]);
return nextEntries;
}
nextEntries.referenced_entities =
entries?.referenced_entities.filter(
(entity_id) =>
this.hass.entities?.[entity_id]?.device_id === rowItem
) || ([] as string[]);
return nextEntries;
});
const entityRows =
this.type === "label" && entries
? entries.referenced_entities.filter((entity_id) => {
const entity = this.hass.entities[entity_id];
if (!entity) {
return false;
}
return (
entity.labels.includes(this.itemId) &&
!entries.referenced_devices.includes(entity.device_id || "")
);
})
: nextType === "device" && entries
? entries.referenced_entities.filter(
(entity_id) =>
this.hass.entities[entity_id]?.area_id === this.itemId
)
: [];
const deviceRows =
this.type === "label" && entries
? entries.referenced_devices.filter(
(device_id) =>
!devicesInAreas.includes(device_id) &&
this.hass.devices[device_id]?.labels.includes(this.itemId)
)
: [];
const deviceRowsEntries =
deviceRows.length === 0
? undefined
: deviceRows.map((device_id) => ({
referenced_areas: [] as string[],
referenced_devices: [] as string[],
referenced_entities:
entries?.referenced_entities.filter(
(entity_id) =>
this.hass.entities?.[entity_id]?.device_id === device_id
) || ([] as string[]),
}));
const nextSubLevel = this.subLevel + 1;
return html`
${rows.map(
${rows1.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
@@ -432,7 +503,7 @@ export class HaTargetPickerItemRow extends LitElement {
.hass=${this.hass}
.type=${nextType}
.itemId=${itemId}
.parentEntries=${rowEntries?.[index]}
.parentEntries=${rows1Entries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
@@ -447,7 +518,7 @@ export class HaTargetPickerItemRow extends LitElement {
.hass=${this.hass}
type="device"
.itemId=${itemId}
.parentEntries=${deviceRowEntries?.[index]}
.parentEntries=${deviceRowsEntries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
@@ -528,53 +599,32 @@ export class HaTargetPickerItemRow extends LitElement {
let referencedDevices = entries.referenced_devices;
const hiddenDeviceIds: string[] = [];
// Parents kept only so a matching child device can nest under them;
// their own entities stay filtered out like a hidden device's.
const groupOnlyDeviceIds = new Set<string>();
if (
this.type === "floor" ||
this.type === "area" ||
this.type === "label"
) {
const matchingDeviceIds = new Set(
referencedDevices.filter((device_id) => {
const device = this.hass.devices[device_id];
return (
!!device &&
!hiddenAreaIds.includes(
getDeviceAreaId(device, this.hass.devices) || ""
) &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
);
})
);
const parentsOfMatching = new Set(
[...matchingDeviceIds].map(
(device_id) => this.hass.devices[device_id].parent_device_id
)
);
referencedDevices = referencedDevices.filter((device_id) => {
// Absent from the registry is not a filter decision: drop the id
// without marking it hidden, like the area filtering above.
if (!this.hass.devices[device_id]) {
const device = this.hass.devices[device_id];
if (!device) {
return false;
}
if (matchingDeviceIds.has(device_id)) {
return true;
}
if (parentsOfMatching.has(device_id)) {
groupOnlyDeviceIds.add(device_id);
if (
!hiddenAreaIds.includes(device.area_id || "") &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
}
hiddenDeviceIds.push(device_id);
return false;
});
@@ -588,10 +638,7 @@ export class HaTargetPickerItemRow extends LitElement {
if (!entity) {
return false;
}
if (
hiddenDeviceIds.includes(entity.device_id || "") ||
groupOnlyDeviceIds.has(entity.device_id || "")
) {
if (hiddenDeviceIds.includes(entity.device_id || "")) {
return false;
}
if (
@@ -627,7 +674,7 @@ export class HaTargetPickerItemRow extends LitElement {
}
}
private _itemData(type: TargetType, item: string) {
private _itemData = memoizeOne((type: TargetType, item: string) => {
if (type === "floor") {
const floor: FloorRegistryEntry | undefined = this.hass.floors?.[item];
return {
@@ -650,25 +697,9 @@ export class HaTargetPickerItemRow extends LitElement {
if (type === "device") {
const device: DeviceRegistryEntry | undefined = this.hass.devices?.[item];
const area = device
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
const parentDevice = device?.parent_device_id
? this.hass.devices[device.parent_device_id]
: undefined;
const context = [
area ? computeAreaName(area) : undefined,
parentDevice ? computeDeviceName(parentDevice) : undefined,
]
.filter(Boolean)
.join(
computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? " ◂ "
: " ▸ "
);
if (device?.primary_config_entry) {
this._getDeviceDomain(device.primary_config_entry);
}
return {
name: device
@@ -678,17 +709,19 @@ export class HaTargetPickerItemRow extends LitElement {
this.hass.states
)
: item,
context,
context: device?.area_id && this.hass.areas?.[device.area_id]?.name,
fallbackIconPath: mdiDevices,
notFound: !device,
};
}
if (type === "entity") {
this._setDomainName(computeDomain(item));
const stateObject: HassEntity | undefined = this.hass.states[item];
const entityName = stateObject
? computeEntityName(stateObject, this.hass.entities, this.hass.devices)
: item;
const { area, device, parentDevice } = stateObject
const { area, device } = stateObject
? getEntityContext(
stateObject,
this.hass.entities,
@@ -696,17 +729,10 @@ export class HaTargetPickerItemRow extends LitElement {
this.hass.areas,
this.hass.floors
)
: { area: undefined, device: undefined, parentDevice: undefined };
: { area: undefined, device: undefined };
const deviceName = device ? computeDeviceName(device) : undefined;
const parentDeviceName = parentDevice
? computeDeviceName(parentDevice)
: undefined;
const areaName = area ? computeAreaName(area) : undefined;
const context = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
const context = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(
computeRTL(
@@ -734,7 +760,7 @@ export class HaTargetPickerItemRow extends LitElement {
fallbackIconPath: mdiLabel,
notFound: !label,
};
}
});
private _setDomainName(domain: string) {
this._domainName = domainToName(this.hass.localize, domain);
-3
View File
@@ -75,7 +75,6 @@ export interface BlueprintAutomationConfig extends ManualAutomationConfig {
}
export interface ForDict {
negative?: boolean;
days?: number;
hours?: number;
minutes?: number;
@@ -664,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 -4
View File
@@ -10,7 +10,6 @@ import {
formatTime,
formatTimeWithSeconds,
} from "../common/datetime/format_time";
import { normalizeDuration } from "../common/datetime/normalize_duration";
import secondsToDuration from "../common/datetime/seconds_to_duration";
import { computeAttributeNameDisplay } from "../common/entity/compute_attribute_display";
import { computeStateName } from "../common/entity/compute_state_name";
@@ -872,9 +871,8 @@ const formatSunOffset = (
return offset;
}
try {
const { negative, ...components } = normalizeDuration(offset);
const formatted = formatDurationDigital(hass.locale, components);
return `${negative ? "-" : "+"}${formatted}`;
const formatted = formatDurationDigital(hass.locale, offset);
return formatted.startsWith("-") ? formatted : `+${formatted}`;
} catch (_e) {
return JSON.stringify(offset);
}
+9 -24
View File
@@ -31,10 +31,6 @@ export const entityComboBoxKeys: FuseWeightedKey[] = [
name: "search_labels.deviceName",
weight: 7,
},
{
name: "search_labels.parentDeviceName",
weight: 6,
},
{
name: "search_labels.areaName",
weight: 6,
@@ -133,20 +129,14 @@ export const getEntities = (
const stateObj = hass.states[entityId];
const friendlyName = computeStateName(stateObj); // Keep this for search
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const domain = computeDomain(entityId);
let domainName = domainNames.get(domain);
@@ -156,11 +146,7 @@ export const getEntities = (
}
const primary = entityName || deviceName || entityId;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
@@ -173,7 +159,6 @@ export const getEntities = (
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
domainName: domainName || null,
friendlyName: friendlyName || null,
+1 -4
View File
@@ -1,12 +1,9 @@
import type { EntityNameType } from "../common/entity/compute_entity_name_display";
export type EntityIdPart = EntityNameType;
export type EntityIdPart = "area" | "device" | "entity" | "floor";
export type EntityIdFormat = EntityIdPart[];
export const DEFAULT_ENTITY_ID_FORMAT: EntityIdFormat = [
"area",
"parent_device",
"device",
"entity",
];
+17 -33
View File
@@ -1,15 +1,14 @@
import type {
HassConfig,
HassEntities,
HassEntity,
HassEntityAttributeBase,
MessageBase,
} from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain";
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
import { computeStateDisplayFromEntityAttributes } from "../common/entity/compute_state_display";
import { computeStateNameFromEntityAttributes } from "../common/entity/compute_state_name";
import type { LocalizeFunc } from "../common/translations/localize";
import { computeRTL } from "../common/util/compute_rtl";
import type { HomeAssistant } from "../types";
import { isNumericSensorDeviceClass } from "./sensor";
import type { FrontendLocaleData } from "./translation";
@@ -337,7 +336,7 @@ const processTimelineEntity = (
localize: LocalizeFunc,
locale: FrontendLocaleData,
config: HassConfig,
hass: HomeAssistant,
entities: HomeAssistant["entities"],
entityId: string,
states: EntityHistoryState[],
current_state: HassEntity | undefined
@@ -359,7 +358,7 @@ const processTimelineEntity = (
localize,
locale,
config,
hass.entities[entityId],
entities[entityId],
entityId,
{
...(state.a || first.a),
@@ -375,16 +374,10 @@ const processTimelineEntity = (
}
return {
name: current_state
? hass.formatEntityName(current_state, DEFAULT_ENTITY_NAME, {
separator: computeRTL(
hass.language,
hass.translationMetadata.translations
)
? " ◂ "
: " ▸ ",
}) || current_state.entity_id
: computeStateNameFromEntityAttributes(entityId, first.a),
name: computeStateNameFromEntityAttributes(
entityId,
current_state?.attributes || first.a
),
entity_id: entityId,
data,
};
@@ -394,15 +387,9 @@ const processLineChartEntities = (
unit: string,
device_class: string | undefined,
entities: HistoryStates,
hass: HomeAssistant
hassEntities: HassEntities
): LineChartUnit => {
const data: LineChartEntity[] = [];
const nameSeparator = computeRTL(
hass.language,
hass.translationMetadata.translations
)
? " ◂ "
: " ▸ ";
const entityIds = Object.keys(entities);
entityIds.forEach((entityId) => {
@@ -449,19 +436,16 @@ const processLineChartEntities = (
processedStates.push(processedState);
}
const name =
entityId in hass.states
? hass.formatEntityName(hass.states[entityId], DEFAULT_ENTITY_NAME, {
separator: nameSeparator,
}) || entityId
: computeStateNameFromEntityAttributes(
entityId,
"friendly_name" in first.a ? first.a : {}
);
const attributes =
entityId in hassEntities
? hassEntities[entityId].attributes
: "friendly_name" in first.a
? first.a
: undefined;
data.push({
domain,
name,
name: computeStateNameFromEntityAttributes(entityId, attributes || {}),
entity_id: entityId,
states: processedStates,
});
@@ -626,7 +610,7 @@ export const computeHistory = (
localize,
hass.locale,
hass.config,
hass,
hass.entities,
entityId,
stateInfo,
currentState
@@ -654,7 +638,7 @@ export const computeHistory = (
unit,
deviceClass,
lineChartDevices[key],
hass
hass.states
);
});
+1 -9
View File
@@ -5,13 +5,12 @@ import type {
import { UNAVAILABLE } from "./entity/entity";
export type LawnMowerEntityState =
"paused" | "mowing" | "returning" | "docked" | "idle" | "error";
"paused" | "mowing" | "returning" | "docked" | "error";
export enum LawnMowerEntityFeature {
START_MOWING = 1,
PAUSE = 2,
DOCK = 4,
STOP = 8,
}
interface LawnMowerEntityAttributes
@@ -39,13 +38,6 @@ export function canPause(stateObj: LawnMowerEntity): boolean {
return stateObj.state !== "paused";
}
export function canStop(stateObj: LawnMowerEntity): boolean {
if (stateObj.state === UNAVAILABLE) {
return false;
}
return !["docked", "idle"].includes(stateObj.state);
}
export function canDock(stateObj: LawnMowerEntity): boolean {
if (stateObj.state === UNAVAILABLE) {
return false;
+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 {
-7
View File
@@ -69,13 +69,6 @@ export const isLocalMediaSourceContentId = (mediaId: string) =>
export const isImageUploadMediaSourceContentId = (mediaId: string) =>
mediaId.startsWith("media-source://image_upload");
export const getImageEntityIdFromMediaSourceContentId = (
mediaId: string
): string | undefined =>
mediaId.startsWith("media-source://image/image.")
? mediaId.slice("media-source://image/".length)
: undefined;
export const uploadLocalMedia = async (
hass: HomeAssistant,
media_content_id: string,
-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);
-8
View File
@@ -266,23 +266,15 @@ export interface LegacyDeviceSelector {
};
}
export type DurationSelectorMode = "positive" | "signed" | "offset";
export interface DurationSelector {
duration: {
enable_day?: boolean;
enable_millisecond?: boolean;
allow_negative?: boolean;
enable_second?: boolean;
mode?: DurationSelectorMode;
} | null;
}
export const getDurationSelectorMode = (
config: DurationSelector["duration"]
): DurationSelectorMode =>
config?.mode ?? (config?.allow_negative ? "signed" : "positive");
interface EntitySelectorFilter {
integration?: string;
domain?: string | readonly string[];
@@ -1,38 +0,0 @@
import { durationDataToSeconds } from "../../common/datetime/duration_to_seconds";
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
import { normalizeDuration } from "../../common/datetime/normalize_duration";
import { formatDurationLong } from "../../common/datetime/format_duration";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { HaDurationData } from "../../components/ha-duration-input";
import type { DurationSelector } from "../selector";
import { getDurationSelectorMode } from "../selector";
import type { FrontendLocaleData } from "../translation";
export const formatDurationSelectorValue = (
localize: LocalizeFunc,
locale: FrontendLocaleData,
value: HaDurationData | string | number | undefined,
config: DurationSelector["duration"],
formatDuration: (
locale: FrontendLocaleData,
duration: HaDurationData
) => string = formatDurationLong
): string => {
const data = durationValueToData(value);
if (!data) {
return "";
}
const { negative, ...components } = normalizeDuration(data);
const mode = getDurationSelectorMode(config);
if (mode === "offset" && durationDataToSeconds(components) === 0) {
return "";
}
const duration = formatDuration(locale, components);
if (!duration || mode === "positive") {
return duration;
}
const sign = negative ? "negative" : "positive";
return localize(`ui.components.selectors.duration.summary.${mode}_${sign}`, {
duration,
});
};
+4 -19
View File
@@ -1,10 +1,8 @@
import { ensureArray } from "../../common/array/ensure-array";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { DEFAULT_ENTITY_NAME } from "../../common/entity/compute_entity_name_display";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import type { HomeAssistant } from "../../types";
import type { Selector } from "../selector";
import { formatDurationSelectorValue } from "./format_duration_selector_value";
export const formatSelectorValue = (
hass: HomeAssistant,
@@ -84,7 +82,10 @@ export const formatSelectorValue = (
if (!stateObj) {
return entityId;
}
const name = hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
const name = hass.formatEntityName(stateObj, [
{ type: "device" },
{ type: "entity" },
]);
return name || entityId;
})
.join(", ");
@@ -103,13 +104,6 @@ export const formatSelectorValue = (
.join(", ");
}
if ("media" in selector) {
const media = ensureArray(value);
return media
.map((item) => item.metadata?.title || item.media_content_id)
.join(", ");
}
if ("object" in selector) {
const { fields } = selector.object ?? {};
const items = ensureArray(value);
@@ -131,15 +125,6 @@ export const formatSelectorValue = (
.join(", ");
}
if ("duration" in selector) {
return formatDurationSelectorValue(
hass.localize,
hass.locale,
value,
selector.duration
);
}
return ensureArray(value)
.map((v) =>
v != null && typeof v === "object" ? JSON.stringify(v) : String(v)
@@ -9,7 +9,6 @@ import { domainToName } from "../../data/integration";
import type { DataEntryFlowDialogParams } from "./show-dialog-data-entry-flow";
import {
loadDataEntryFlowDialog,
loadFlowStepTranslations,
showFlowDialog,
} from "./show-dialog-data-entry-flow";
@@ -33,7 +32,6 @@ export const showConfigFlowDialog = (
// Used as fallback if no header defined for step
hass.loadBackendTranslation("title", handler),
]);
await loadFlowStepTranslations(hass, step);
return step;
},
fetchFlow: async (hass, flowId) => {
@@ -47,14 +45,9 @@ export const showConfigFlowDialog = (
// Used as fallback if no header defined for step
hass.loadBackendTranslation("title", step.handler),
]);
await loadFlowStepTranslations(hass, step);
return step;
},
handleFlowStep: async (hass, flowId, data) => {
const step = await handleConfigFlowStep(hass, flowId, data);
await loadFlowStepTranslations(hass, step);
return step;
},
handleFlowStep: handleConfigFlowStep,
deleteFlow: deleteConfigFlow,
renderAbortDescription(hass, step) {
@@ -154,19 +154,6 @@ export interface FlowConfig {
export type LoadingReason =
"loading_handlers" | "loading_flow" | "loading_step";
/**
* Load the translations a step resolves against when it points at another
* integration, which owns strings shared between integrations.
*/
export const loadFlowStepTranslations = async (
hass: HomeAssistant,
step: DataEntryFlowStep
): Promise<void> => {
if ("translation_domain" in step && step.translation_domain) {
await hass.loadBackendTranslation("config", step.translation_domain);
}
};
export interface DataEntryFlowDialogParams {
startFlowHandler?: string;
searchQuery?: string;
@@ -34,8 +34,6 @@ export const showSubConfigFlowDialog = (
hass.loadBackendTranslation("selector", configEntry.domain),
// Used as fallback if no header defined for step
hass.loadBackendTranslation("title", configEntry.domain),
// Shared abort reasons live in the homeassistant integration
hass.loadBackendTranslation("config", "homeassistant"),
]);
return step;
},
@@ -48,24 +46,16 @@ export const showSubConfigFlowDialog = (
configEntry.domain
);
await hass.loadBackendTranslation("selector", configEntry.domain);
await hass.loadBackendTranslation("config", "homeassistant");
return step;
},
handleFlowStep: handleSubConfigFlowStep,
deleteFlow: deleteSubConfigFlow,
renderAbortDescription(hass, step) {
// A translation domain means the reason is shared by several integrations
// and is defined once under `config`, not per subentry type
const description = step.translation_domain
? hass.localize(
`component.${step.translation_domain}.config.abort.${step.reason}`,
step.description_placeholders
)
: hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.abort.${step.reason}`,
step.description_placeholders
);
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.abort.${step.reason}`,
step.description_placeholders
);
return description
? html`
@@ -1,5 +1,5 @@
import { consume } from "@lit/context";
import { mdiHomeImportOutline, mdiPause, mdiPlay, mdiStop } from "@mdi/js";
import { mdiHomeImportOutline, mdiPause, mdiPlay } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -29,7 +29,6 @@ import {
LawnMowerEntityFeature,
canDock,
canStartMowing,
canStop,
isMowing,
} from "../../../data/lawn_mower";
import "../../../state-control/lawn_mower/ha-state-control-lawn_mower-status";
@@ -174,14 +173,6 @@ class MoreInfoLawnMower extends LitElement {
}
}
private _handleStop() {
if (!this.stateObj) return;
forwardHaptic(this, "light");
this._api.callService("lawn_mower", "stop", {
entity_id: this.stateObj.entity_id,
});
}
private _handleDock() {
if (!this.stateObj) return;
forwardHaptic(this, "light");
@@ -197,11 +188,9 @@ class MoreInfoLawnMower extends LitElement {
const stateObj = this.stateObj;
const isUnavailable = stateObj.state === UNAVAILABLE;
const supportsStop = supportsFeature(stateObj, LawnMowerEntityFeature.STOP);
const supportsDock = supportsFeature(stateObj, LawnMowerEntityFeature.DOCK);
const hasAnyCommand =
this._supportsStartPause || supportsStop || supportsDock;
const hasAnyCommand = this._supportsStartPause || supportsDock;
return html`
<ha-more-info-state-header .stateObj=${this.stateObj}>
@@ -233,21 +222,6 @@ class MoreInfoLawnMower extends LitElement {
`
: nothing
}
${
supportsStop
? html`
<ha-control-button
.label=${this._i18n.localize(
"ui.dialogs.more_info_control.lawn_mower.stop"
)}
@click=${this._handleStop}
.disabled=${isUnavailable || !canStop(stateObj)}
>
<ha-svg-icon .path=${mdiStop}></ha-svg-icon>
</ha-control-button>
`
: nothing
}
${
supportsDock
? html`
@@ -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;
}
`;
}
+3 -9
View File
@@ -591,17 +591,11 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
const deviceName = context?.device
? computeDeviceName(context.device)
: undefined;
const parentDeviceName = context?.parentDevice
? computeDeviceName(context.parentDevice)
: undefined;
const areaName = context?.area ? computeAreaName(context.area) : undefined;
const breadcrumb = [
areaName,
parentDeviceName,
deviceName,
entityName,
].filter((v): v is string => Boolean(v));
const breadcrumb = [areaName, deviceName, entityName].filter(
(v): v is string => Boolean(v)
);
const addToMenuItem = this.hass.localize(
"ui.dialogs.more_info_control.add_to.item"
);
+1 -16
View File
@@ -82,7 +82,7 @@ class HaMoreInfoRelated extends LitElement {
? (domain as ItemType)
: "entity";
const { floor, area, device, parentDevice } = getEntityContext(
const { floor, area, device } = getEntityContext(
this._stateObj,
this.hass.entities,
this.hass.devices,
@@ -91,13 +91,6 @@ class HaMoreInfoRelated extends LitElement {
);
const floorName = floor ? computeFloorName(floor) : undefined;
const areaName = area ? (computeAreaName(area) ?? area.area_id) : undefined;
const parentDeviceName = parentDevice
? computeDeviceNameDisplay(
parentDevice,
this.hass.localize,
this.hass.states
)
: undefined;
const deviceName = device
? computeDeviceNameDisplay(device, this.hass.localize, this.hass.states)
: undefined;
@@ -131,14 +124,6 @@ class HaMoreInfoRelated extends LitElement {
: html`<ha-svg-icon slot="end" .path=${mdiTextureBox}></ha-svg-icon>`,
});
}
if (parentDevice && parentDeviceName) {
contextEntries.push({
translationKey: "ui.dialogs.more_info_control.parent_device",
value: parentDeviceName,
href: `/config/devices/device/${parentDevice.id}`,
icon: html`<ha-svg-icon slot="end" .path=${mdiDevices}></ha-svg-icon>`,
});
}
if (device && deviceName) {
contextEntries.push({
translationKey: "ui.components.related-items.device",
@@ -24,11 +24,6 @@ export class MockLawnMowerEntity extends MockBaseEntity {
return;
}
if (service === "stop") {
this.update({ state: "idle" });
return;
}
if (service === "dock") {
this._transition("returning", "docked", TRANSITION_MS);
return;
+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
);
}
+4 -17
View File
@@ -17,7 +17,6 @@ import { applyThemesOnElement } from "../common/dom/apply_themes_on_element";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
import { navigate } from "../common/navigate";
import { buildLiteInternationalization } from "../common/translations/lite-internationalization";
import {
addSearchParam,
extractSearchParam,
@@ -25,7 +24,6 @@ import {
} from "../common/url/search-params";
import { subscribeOne } from "../common/util/subscribe-one";
import "../components/ha-card";
import "../components/progress/ha-progress-bar";
import type { AuthUrlSearchParams } from "../data/auth";
import { hassUrl } from "../data/auth";
import { saveFrontendSystemData } from "../data/frontend";
@@ -42,6 +40,7 @@ import { HassElement } from "../state/hass-element";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import { storeState } from "../util/ha-pref-storage";
import { registerServiceWorker } from "../util/register-service-worker";
import "../components/progress/ha-progress-bar";
import "./onboarding-analytics";
import "./onboarding-create-user";
import "./onboarding-loading";
@@ -166,7 +165,9 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
}
if (this._init) {
return html`<onboarding-welcome></onboarding-welcome>`;
return html`<onboarding-welcome
.localize=${this.localize}
></onboarding-welcome>`;
}
const step = this._curStep()!;
@@ -229,20 +230,6 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
import("../components/ha-language-picker");
}
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Before `hass` connects, feed the context providers from the lite localize
// state so context-consuming components render on the onboarding screens.
if (
!this.hass &&
(changedProps.has("localize") || changedProps.has("language"))
) {
this._provideLiteInternationalization(
buildLiteInternationalization(this.language, this.localize)
);
}
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("_page")) {
-1
View File
@@ -267,7 +267,6 @@ class OnboardingLocation extends LitElement {
? location[1]
: Number(place.lon),
location_editable: place.place_id === highlightedMarker,
clickable: true,
}))
: [];
}
+12 -12
View File
@@ -1,36 +1,36 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-button";
import "../components/ha-icon-next";
import "../components/item/ha-list-item-button";
import "../components/list/ha-list-base";
import type { HomeAssistant } from "../types";
import { onBoardingStyles } from "./styles";
@customElement("onboarding-welcome")
class OnboardingWelcome extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public localize!: LocalizeFunc;
protected render(): TemplateResult {
return html`
<h1>${this._localize("ui.panel.page-onboarding.welcome.header")}</h1>
<p>${this._localize("ui.panel.page-onboarding.intro")}</p>
<h1>${this.localize("ui.panel.page-onboarding.welcome.header")}</h1>
<p>${this.localize("ui.panel.page-onboarding.intro")}</p>
<ha-button @click=${this._start} class="start">
${this._localize("ui.panel.page-onboarding.welcome.start")}
${this.localize("ui.panel.page-onboarding.welcome.start")}
</ha-button>
<div class="divider">
<wa-divider></wa-divider>
<div>
<span
>${this._localize(
>${this.localize(
"ui.panel.page-onboarding.welcome.or_restore"
)}</span
>
@@ -40,10 +40,10 @@ class OnboardingWelcome extends LitElement {
<ha-list-base>
<ha-list-item-button @click=${this._restoreBackupUpload}>
<div slot="headline">
${this._localize("ui.panel.page-onboarding.restore.upload_backup")}
${this.localize("ui.panel.page-onboarding.restore.upload_backup")}
</div>
<div slot="supporting-text">
${this._localize(
${this.localize(
"ui.panel.page-onboarding.restore.options.upload_description"
)}
</div>
@@ -52,7 +52,7 @@ class OnboardingWelcome extends LitElement {
<ha-list-item-button @click=${this._restoreBackupCloud}>
<div slot="headline">Home Assistant Cloud</div>
<div slot="supporting-text">
${this._localize(
${this.localize(
"ui.panel.page-onboarding.restore.ha-cloud.description"
)}
</div>
@@ -175,6 +175,8 @@ class SupervisorAppsCardContent extends LitElement {
line-height: var(--ha-line-height-condensed);
}
.footer {
border-top: var(--ha-border-width-sm) solid
var(--ha-color-border-neutral-quiet);
padding-top: var(--ha-space-2);
display: flex;
gap: var(--ha-space-2);
@@ -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";
@@ -13,11 +19,9 @@ import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import { mainWindow } from "../../../common/dom/get_main_window";
import { computeAreaName } from "../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeEntityNameList } from "../../../common/entity/compute_entity_name_display";
import { computeFloorName } from "../../../common/entity/compute_floor_name";
import { getDeviceArea } from "../../../common/entity/context/get_device_context";
import { isNumericState } from "../../../common/number/format_number";
import { stringCompare } from "../../../common/string/compare";
import type {
@@ -118,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";
@@ -720,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
@@ -732,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`
${
@@ -903,12 +948,6 @@ class DialogAddAutomationElement
const [targetType, targetId] = this._extractTypeAndIdFromTarget(
this._selectedTarget
);
const separator = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? " ◂ "
: " ▸ ";
if (targetId) {
if (targetType === "area") {
@@ -921,21 +960,11 @@ class DialogAddAutomationElement
);
}
} else if (targetType === "device") {
const device = this.hass.devices[targetId];
const area = device
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
const parentDevice = device?.parent_device_id
? this.hass.devices[device.parent_device_id]
: undefined;
if (area) {
subtitle = [
computeAreaName(area) || area.area_id,
parentDevice ? computeDeviceName(parentDevice) : undefined,
]
.filter(Boolean)
.join(separator);
const areaId = this.hass.devices[targetId]?.area_id;
if (areaId) {
subtitle = computeAreaName(this.hass.areas[areaId]) || areaId;
} else {
const device = this.hass.devices[targetId];
subtitle = this.hass.localize(
`ui.panel.config.automation.editor.${device?.entry_type === "service" ? "services" : "unassigned_devices"}`
);
@@ -951,28 +980,25 @@ class DialogAddAutomationElement
);
} else {
const stateObj = this.hass.states[targetId];
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
subtitle = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
subtitle = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(separator);
.join(
computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? " ◂ "
: " ▸ "
);
}
}
@@ -2421,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 {
@@ -2501,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;
}
}
@@ -20,7 +20,6 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
import { computeEntityNameList } from "../../../../common/entity/compute_entity_name_display";
import { getDeviceAreaId } from "../../../../common/entity/context/get_device_context";
import { stringCompare } from "../../../../common/string/compare";
import "../../../../components/ha-floor-icon";
import "../../../../components/ha-icon";
@@ -31,7 +30,10 @@ import "../../../../components/ha-svg-icon";
import "../../../../components/item/ha-list-item-button";
import "../../../../components/item/ha-row-item";
import "../../../../components/list/ha-list-base";
import { getAreaEntityLookup } from "../../../../data/area/area_registry";
import {
getAreaDeviceLookup,
getAreaEntityLookup,
} from "../../../../data/area/area_registry";
import {
getAreasNestedInFloors,
type AreaFloorValue,
@@ -49,10 +51,7 @@ import {
registriesContext,
statesContext,
} from "../../../../data/context";
import {
getDeviceEntityLookup,
type DeviceRegistryEntry,
} from "../../../../data/device/device_registry";
import { getDeviceEntityLookup } from "../../../../data/device/device_registry";
import {
domainToName,
type DomainManifestLookup,
@@ -66,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;
@@ -84,7 +81,6 @@ interface Level2Entries {
interface Level3Entries {
open: boolean;
entities: string[];
devices?: Record<string, Level3Entries>;
}
@customElement("ha-automation-add-from-target")
@@ -109,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
@@ -202,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,
@@ -283,19 +268,23 @@ export default class HaAutomationAddFromTarget extends LitElement {
}
if (valueId && valueType === "device") {
const entry = this._getDeviceEntry(entries, valueId);
if (!entry) {
return nothing;
const areaId = this._registries.devices[valueId]?.area_id;
if (areaId) {
const floorId = this._registries.areas[areaId]?.floor_id || "";
const { entities } =
entries[`floor${TARGET_SEPARATOR}${floorId}`].areas![
`area${TARGET_SEPARATOR}${areaId}`
].devices![valueId];
return entities.length ? this._renderEntities(entities) : nothing;
}
const numberOfDevices = Object.keys(entry.devices ?? {}).length;
return html`
${numberOfDevices ? this._renderDevices(entry.devices!) : nothing}
${
entry.entities.length
? this._renderEntities(entry.entities)
: nothing
}
`;
const device = this._registries.devices[valueId];
const isService = device.entry_type === "service";
const { entities } =
entries[`${isService ? "service" : "area"}${TARGET_SEPARATOR}`]
.devices![valueId];
return entities.length ? this._renderEntities(entities) : nothing;
}
if (valueType === "device" || valueType === "helper") {
@@ -355,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
>`
}`
}`;
}
);
@@ -678,8 +663,7 @@ export default class HaAutomationAddFromTarget extends LitElement {
stringCompare(deviceNameA, deviceNameB, this.hass.locale.language)
)
.map(([deviceId, deviceName, domain]) => {
const { open, entities, devices: children } = devices[deviceId];
const numberOfChildren = Object.keys(children ?? {}).length;
const { open, entities } = devices[deviceId];
return this._renderItem(
deviceName || deviceId,
@@ -687,15 +671,10 @@ export default class HaAutomationAddFromTarget extends LitElement {
false,
this._getSelectedTargetId(this.value) ===
`device${TARGET_SEPARATOR}${deviceId}`,
!open && !!(entities.length || numberOfChildren),
!open && !!entities.length,
open,
domain ? this._renderDomainIcon(domain) : undefined,
open
? html`
${numberOfChildren ? this._renderDevices(children!) : nothing}
${this._renderEntities(entities)}
`
: undefined
open ? this._renderEntities(entities) : undefined
);
});
@@ -903,16 +882,8 @@ export default class HaAutomationAddFromTarget extends LitElement {
// #region memoized data helpers
private _getAreaDeviceLookupMemoized = memoizeOne(
(devices: HomeAssistant["devices"]) => {
const lookup: Record<string, DeviceRegistryEntry[]> = {};
for (const device of Object.values(devices)) {
const areaId = getDeviceAreaId(device, devices);
if (areaId) {
(lookup[areaId] ??= []).push(device);
}
}
return lookup;
}
(devices: HomeAssistant["devices"]) =>
getAreaDeviceLookup(Object.values(devices))
);
private _getAreaEntityLookupMemoized = memoizeOne(
@@ -986,18 +957,32 @@ export default class HaAutomationAddFromTarget extends LitElement {
private _loadUnassignedDevices() {
const unassignedDevices = Object.values(this._registries.devices).filter(
(device) =>
!device.disabled_by &&
!getDeviceAreaId(device, this._registries.devices)
(device) => !device.area_id
);
const devices = this._buildDeviceEntries(
unassignedDevices.filter((device) => device.entry_type !== "service")
);
const devices: Record<string, Level3Entries> = {};
const services = this._buildDeviceEntries(
unassignedDevices.filter((device) => device.entry_type === "service")
);
const services: Record<string, Level3Entries> = {};
unassignedDevices.forEach(({ id: deviceId, entry_type }) => {
const device = this._registries.devices[deviceId];
if (!device || device.disabled_by) {
return;
}
const deviceEntry = {
open: false,
entities:
this._getDeviceEntityLookupMemoized(this._registries.entities)[
deviceId
]?.map((entity) => entity.entity_id) || [],
};
if (entry_type === "service") {
services[deviceId] = deviceEntry;
return;
}
devices[deviceId] = deviceEntry;
});
if (Object.keys(devices).length) {
this._entries = {
@@ -1077,21 +1062,31 @@ export default class HaAutomationAddFromTarget extends LitElement {
private _loadArea(area: FloorComboBoxItem) {
const [, id] = area.id.split(TARGET_SEPARATOR, 2);
const referenced_devices = (
this._getAreaDeviceLookupMemoized(this._registries.devices)[id] || []
).filter((device) => !device.disabled_by);
const referenced_devices =
this._getAreaDeviceLookupMemoized(this._registries.devices)[id] || [];
const referenced_entities =
this._getAreaEntityLookupMemoized(this._registries.entities)[id] || [];
const devices = this._buildDeviceEntries(referenced_devices);
const areaDeviceIds = new Set(
referenced_devices.map((device) => device.id)
);
const devices: Record<string, Level3Entries> = {};
referenced_devices.forEach(({ id: deviceId }) => {
const device = this._registries.devices[deviceId];
if (!device || device.disabled_by) {
return;
}
devices[deviceId] = {
open: false,
entities:
this._getDeviceEntityLookupMemoized(this._registries.entities)[
deviceId
]?.map((entity) => entity.entity_id) || [],
};
});
const entities: string[] = [];
referenced_entities.forEach((entity) => {
if (!entity.device_id || !areaDeviceIds.has(entity.device_id)) {
if (!entity.device_id || !devices[entity.device_id]) {
entities.push(entity.entity_id);
}
});
@@ -1103,160 +1098,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
};
}
private _buildDeviceEntries(
devices: DeviceRegistryEntry[]
): Record<string, Level3Entries> {
const deviceEntityLookup = this._getDeviceEntityLookupMemoized(
this._registries.entities
);
const entryFor = (deviceId: string): Level3Entries => ({
open: false,
entities:
deviceEntityLookup[deviceId]?.map((entity) => entity.entity_id) || [],
});
const deviceIds = new Set(devices.map((device) => device.id));
const nested = (device: DeviceRegistryEntry) =>
!!device.parent_device_id && deviceIds.has(device.parent_device_id);
const roots = devices.filter((device) => !nested(device));
const children = devices.filter(nested);
const entries: Record<string, Level3Entries> = {};
for (const device of roots) {
entries[device.id] = entryFor(device.id);
}
for (const device of children) {
(entries[device.parent_device_id!].devices ??= {})[device.id] = entryFor(
device.id
);
}
return entries;
}
private _deviceGroupPath(
device: DeviceRegistryEntry
): [level1: string, level2?: string] {
const areaId = getDeviceAreaId(device, this._registries.devices);
if (areaId) {
return [
`floor${TARGET_SEPARATOR}${this._registries.areas[areaId]?.floor_id || ""}`,
`area${TARGET_SEPARATOR}${areaId}`,
];
}
return [
`${device.entry_type === "service" ? "service" : "area"}${TARGET_SEPARATOR}`,
];
}
private _deviceGroup(
entries: Record<string, Level1Entries>,
device: DeviceRegistryEntry
): Record<string, Level3Entries> | undefined {
const [level1, level2] = this._deviceGroupPath(device);
const entry = entries[level1];
return level2 ? entry?.areas?.[level2]?.devices : entry?.devices;
}
private _deviceChain(
group: Record<string, Level3Entries> | undefined,
device: DeviceRegistryEntry
): string[] {
const parentId = device.parent_device_id;
return parentId && group?.[parentId]?.devices?.[device.id]
? [parentId, device.id]
: [device.id];
}
private _getDeviceEntry(
entries: Record<string, Level1Entries>,
deviceId: string
): Level3Entries | undefined {
const device = this._registries.devices[deviceId];
if (!device) {
return undefined;
}
const group = this._deviceGroup(entries, device);
let level = group;
let entry: Level3Entries | undefined;
for (const key of this._deviceChain(group, device)) {
entry = level?.[key];
level = entry?.devices;
}
return entry;
}
private _setDeviceOpen(
deviceId: string,
deviceOpen: boolean | undefined,
openAncestors: boolean
) {
const device = this._registries.devices[deviceId];
if (!device) {
return;
}
const [level1, level2] = this._deviceGroupPath(device);
const group = this._deviceGroup(this._entries, device);
if (!group) {
return;
}
const chain = this._deviceChain(group, device);
const updateGroup = (
devices: Record<string, Level3Entries>,
[key, ...rest]: string[]
): Record<string, Level3Entries> => {
const entry = devices[key];
if (!entry) {
return devices;
}
const isTarget = !rest.length;
return {
...devices,
[key]: {
...entry,
open: isTarget
? (deviceOpen ?? entry.open)
: openAncestors || entry.open,
devices:
isTarget || !entry.devices
? entry.devices
: updateGroup(entry.devices, rest),
},
};
};
const level1Entry = this._entries[level1];
if (!level2) {
this._entries = {
...this._entries,
[level1]: {
...level1Entry,
open: openAncestors || level1Entry.open,
devices: updateGroup(group, chain),
},
};
return;
}
const level2Entry = level1Entry.areas![level2];
this._entries = {
...this._entries,
[level1]: {
...level1Entry,
open: openAncestors || level1Entry.open,
areas: {
...level1Entry.areas,
[level2]: {
...level2Entry,
open: openAncestors || level2Entry.open,
devices: updateGroup(group, chain),
},
},
},
};
}
private _expandTreeToItem(type: string, id: string) {
if (type === "floor" || type === "label") {
return;
@@ -1264,26 +1105,39 @@ export default class HaAutomationAddFromTarget extends LitElement {
if (type === "entity") {
const deviceId = this._registries.entities[id]?.device_id;
if (deviceId && this._registries.devices[deviceId]) {
this._setDeviceOpen(deviceId, true, true);
return;
}
const device = deviceId ? this._registries.devices[deviceId] : undefined;
const deviceAreaId = (deviceId && device?.area_id) || undefined;
const entity = this._registries.entities[id];
if (!deviceAreaId) {
let floor: string;
let area: string;
const entity = this._registries.entities[id];
if (entity?.area_id) {
const floor = `floor${TARGET_SEPARATOR}${this._registries.areas[entity.area_id]?.floor_id || ""}`;
const area = `area${TARGET_SEPARATOR}${entity.area_id}`;
const floorEntry = this._entries[floor];
if (!deviceId && entity.area_id) {
floor = `floor${TARGET_SEPARATOR}${this._registries.areas[entity.area_id]?.floor_id || ""}`;
area = `area${TARGET_SEPARATOR}${entity.area_id}`;
} else if (!deviceId) {
const domain = id.split(".", 1)[0];
const isHelper =
this.manifests![domain]?.integration_type === "helper";
floor = isHelper
? `helper${TARGET_SEPARATOR}`
: `device${TARGET_SEPARATOR}`;
area = `${isHelper ? "helper_" : "entity_"}${domain}${TARGET_SEPARATOR}`;
} else {
floor = `${device!.entry_type === "service" ? "service" : "area"}${TARGET_SEPARATOR}`;
area = deviceId;
}
this._entries = {
...this._entries,
[floor]: {
...floorEntry,
...this._entries[floor],
open: true,
areas: {
...floorEntry.areas,
devices: {
...this._entries[floor].devices!,
[area]: {
...floorEntry.areas![area],
...this._entries[floor].devices![area],
open: true,
},
},
@@ -1292,23 +1146,26 @@ export default class HaAutomationAddFromTarget extends LitElement {
return;
}
const domain = id.split(".", 1)[0];
const isHelper = this.manifests![domain]?.integration_type === "helper";
const group = isHelper
? `helper${TARGET_SEPARATOR}`
: `device${TARGET_SEPARATOR}`;
const domainGroup = `${isHelper ? "helper_" : "entity_"}${domain}${TARGET_SEPARATOR}`;
const groupEntry = this._entries[group];
const floor = `floor${TARGET_SEPARATOR}${this._registries.areas[deviceAreaId]?.floor_id || ""}`;
const area = `area${TARGET_SEPARATOR}${deviceAreaId}`;
this._entries = {
...this._entries,
[group]: {
...groupEntry,
[floor]: {
...this._entries[floor],
open: true,
devices: {
...groupEntry.devices,
[domainGroup]: {
...groupEntry.devices![domainGroup],
areas: {
...this._entries[floor].areas!,
[area]: {
...this._entries[floor].areas![area],
open: true,
devices: {
...this._entries[floor].areas![area].devices,
[deviceId!]: {
...this._entries[floor].areas![area].devices![deviceId!],
open: true,
},
},
},
},
},
@@ -1317,7 +1174,38 @@ export default class HaAutomationAddFromTarget extends LitElement {
}
if (type === "device") {
this._setDeviceOpen(id, undefined, true);
const deviceAreaId = this._registries.devices[id]?.area_id;
if (!deviceAreaId) {
const device = this._registries.devices[id];
const floor = `${device.entry_type === "service" ? "service" : "area"}${TARGET_SEPARATOR}`;
this._entries = {
...this._entries,
[floor]: {
...this._entries[floor],
open: true,
},
};
return;
}
const floor = `floor${TARGET_SEPARATOR}${this._registries.areas[deviceAreaId]?.floor_id || ""}`;
const area = `area${TARGET_SEPARATOR}${deviceAreaId}`;
this._entries = {
...this._entries,
[floor]: {
...this._entries[floor],
open: true,
areas: {
...this._entries[floor].areas!,
[area]: {
...this._entries[floor].areas![area],
open: true,
},
},
},
};
return;
}
@@ -1443,7 +1331,51 @@ export default class HaAutomationAddFromTarget extends LitElement {
}
if (type === "device" && id) {
this._setDeviceOpen(id, open, false);
const areaId = this._registries.devices[id]?.area_id;
if (areaId) {
const areaTargetId = `area${TARGET_SEPARATOR}${this._registries.devices[id]?.area_id ?? ""}`;
const floorId = `floor${TARGET_SEPARATOR}${(areaId && this._registries.areas[areaId]?.floor_id) || ""}`;
this._entries = {
...this._entries,
[floorId]: {
...this._entries[floorId],
areas: {
...this._entries[floorId].areas,
[areaTargetId]: {
...this._entries[floorId].areas![areaTargetId],
devices: {
...this._entries[floorId].areas![areaTargetId].devices,
[id]: {
...this._entries[floorId].areas![areaTargetId].devices[id],
open,
},
},
},
},
},
};
return;
}
const deviceType =
this._registries.devices[id]?.entry_type === "service"
? "service"
: "area";
const floorId = `${deviceType}${TARGET_SEPARATOR}`;
this._entries = {
...this._entries,
[floorId]: {
...this._entries[floorId],
devices: {
...this._entries[floorId].devices,
[id]: {
...this._entries[floorId].devices![id],
open,
},
},
},
};
return;
}
@@ -5,6 +5,7 @@ import {
mdiArrowDown,
mdiArrowUp,
mdiCommentEditOutline,
mdiCommentTextOutline,
mdiContentCopy,
mdiContentCut,
mdiContentPaste,
@@ -18,6 +19,7 @@ import {
mdiStopCircleOutline,
} from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { dump } from "js-yaml";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit";
@@ -30,10 +32,10 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import { preventDefaultStopPropagation } from "../../../../common/dom/prevent_default_stop_propagation";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
import { truncateWithEllipsis } from "../../../../common/string/truncate-with-ellipsis";
import { handleStructError } from "../../../../common/structs/handle-errors";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/automation/ha-automation-condition-live-test";
import "../../../../components/automation/ha-automation-condition-summary";
import "../../../../components/automation/ha-automation-row";
import type { HaAutomationRow } from "../../../../components/automation/ha-automation-row";
import "../../../../components/automation/ha-automation-row-event-chip";
@@ -62,6 +64,7 @@ import {
} from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { TargetSelector } from "../../../../data/selector";
import {
showAlertDialog,
showPromptDialog,
@@ -70,7 +73,12 @@ import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import "../ha-automation-row-behavior";
import "../ha-automation-row-options";
import { overflowStyles, rowStyles } from "../styles";
import { getDeviceTarget } from "../target/get_device_target";
import { getEntityTarget } from "../target/get_entity_target";
import "../target/ha-automation-row-targets";
import "./ha-automation-condition-editor";
import type HaAutomationConditionEditor from "./ha-automation-condition-editor";
import "./types/ha-automation-condition-and";
@@ -173,6 +181,26 @@ export default class HaAutomationConditionRow extends LitElement {
}
private _renderRow() {
const descriptionHasTarget =
"target" in (this.conditionDescriptions[this.condition.condition] || {});
const hasEntityTarget =
this.condition.condition === "state" ||
this.condition.condition === "numeric_state";
const target = this._getTarget(descriptionHasTarget, hasEntityTarget);
const targetRequired =
(descriptionHasTarget || hasEntityTarget) && !this._isNew;
const conditionTargetSpec =
this.conditionDescriptions[this.condition.condition]?.target;
const noteTooltipText = truncateWithEllipsis(
this.condition.note?.trim() || "",
250
);
return html`
${
this.optionsInSidebar && this.condition.condition !== "trigger"
@@ -198,15 +226,56 @@ export default class HaAutomationConditionRow extends LitElement {
></ha-condition-icon>
</div>`
}
<ha-automation-condition-summary
slot="header"
.label=${capitalizeFirstLetter(
<h3 slot="header">
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
)}
.condition=${this.condition}
.description=${this.conditionDescriptions[this.condition.condition]}
.isNew=${this._isNew}
></ha-automation-condition-summary>
${
this._getType(this.condition, this.conditionDescriptions) ===
"platform"
? html`<ha-automation-row-behavior
mode="condition"
.config=${this.condition}
></ha-automation-row-behavior>`
: nothing
}
${
target !== undefined || targetRequired
? this._renderTargets(
target,
targetRequired,
conditionTargetSpec,
this.condition.condition !== "device"
)
: nothing
}
${
this._getType(this.condition, this.conditionDescriptions) ===
"platform"
? html`<ha-automation-row-options
.config=${this.condition}
></ha-automation-row-options>`
: nothing
}
${
this.condition.note?.trim()
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
)}
class="note-indicator"
></ha-svg-icon>
<ha-tooltip for="note-icon"
><p>${noteTooltipText}</p></ha-tooltip
>
`
: nothing
}
</h3>
<ha-automation-row-event-chip
.show=${this._testing}
.variant=${this._testingResult ? "success" : "warning"}
@@ -552,6 +621,45 @@ export default class HaAutomationConditionRow extends LitElement {
`;
}
private _getEntityTarget = memoizeOne(getEntityTarget);
private _getDeviceTarget = memoizeOne(getDeviceTarget);
private _getTarget(
descriptionHasTarget: boolean,
hasEntityTarget: boolean
): HassServiceTarget | undefined {
if (descriptionHasTarget && "target" in this.condition) {
return this.condition.target;
}
if (
"entity_id" in this.condition &&
this.condition.entity_id &&
hasEntityTarget
) {
return this._getEntityTarget(this.condition.entity_id);
}
if ("device_id" in this.condition && this.condition.device_id) {
return this._getDeviceTarget(this.condition.device_id);
}
return undefined;
}
private _renderTargets = memoizeOne(
(
target?: HassServiceTarget,
targetRequired = false,
targetSpec?: TargetSelector["target"],
interactive = false
) =>
html`<ha-automation-row-targets
.target=${target}
.targetRequired=${targetRequired}
.selector=${targetSpec ? { target: targetSpec } : undefined}
.interactive=${interactive}
></ha-automation-row-targets>`
);
protected firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);

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