mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-17 07:30:24 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f45689676 | ||
|
|
6ccc78114c | ||
|
|
ec4bfc9255 | ||
|
|
3b9e026aff | ||
|
|
569bcc01da | ||
|
|
8ea28508c3 | ||
|
|
c3e341bdb3 | ||
|
|
58de8df46a | ||
|
|
3447e1d862 | ||
|
|
105a2a2423 | ||
|
|
27efa82a89 | ||
|
|
19cf7f2613 | ||
|
|
fcd25e41a0 | ||
|
|
f942a5afe5 | ||
|
|
89b745b851 | ||
|
|
ac582cbfbb | ||
|
|
dab85a9239 | ||
|
|
29e0e9db87 | ||
|
|
3ce90a7a68 | ||
|
|
d6260482e0 | ||
|
|
ca24b16702 | ||
|
|
380bcb8315 | ||
|
|
9902924ffa | ||
|
|
cb0c03ab8b | ||
|
|
64cda9e0e7 | ||
|
|
94fdea6f00 | ||
|
|
37de8c4b15 | ||
|
|
841729af93 | ||
|
|
e3f92a6df3 | ||
|
|
a781228706 | ||
|
|
f9e02b628f | ||
|
|
af8a80f0fe |
@@ -7,6 +7,12 @@ 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.
|
||||
@@ -116,4 +122,3 @@ For user-facing changes, establish the existing design context as part of fronte
|
||||
- Record the applicable UI/UX evidence for user-facing changes, whether or not further input is needed.
|
||||
- Keep style-only comments secondary unless they affect maintainability or user experience.
|
||||
- Prefer small, direct fixes over large refactors during review follow-up.
|
||||
- Load the matching `ha-frontend-*` skill when a finding falls within its area.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -37,7 +37,7 @@ Never run `tsc` or `yarn lint:types` with file arguments. When `tsc` receives fi
|
||||
|
||||
## Project Skills
|
||||
|
||||
Detailed guidance lives in project skills under `.agents/skills/`. Load the matching skill before detailed implementation or review:
|
||||
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:
|
||||
|
||||
- `ha-frontend-contexts`: Lit contexts, `hass` migration, and rerender-sensitive state access.
|
||||
- `ha-frontend-components`: dialogs, forms, alerts, shortcuts, tooltips, panels, and Lovelace cards.
|
||||
|
||||
@@ -81,6 +81,8 @@ 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) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -16,6 +17,7 @@ import { zwaveJsFixtures } from "./zwave_js/fixtures";
|
||||
const INTEGRATIONS: ConnectivityFixtures[] = [
|
||||
bluetoothFixtures,
|
||||
serialFixtures,
|
||||
modbusFixtures,
|
||||
mqttFixtures,
|
||||
matterFixtures,
|
||||
infraredFixtures,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -12,6 +13,7 @@ import { mockZwaveJs } from "./zwave_js/mock";
|
||||
const MOCKS = [
|
||||
mockBluetooth,
|
||||
mockSerial,
|
||||
mockModbus,
|
||||
mockMatter,
|
||||
mockMqtt,
|
||||
mockZwaveJs,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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"),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
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 }));
|
||||
};
|
||||
@@ -1,5 +1,12 @@
|
||||
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[] = [
|
||||
{
|
||||
@@ -55,7 +62,7 @@ const PORTS: SerialPortUsage[] = [
|
||||
discovery_flows: [],
|
||||
},
|
||||
{
|
||||
device: "/dev/ttyUSB1",
|
||||
device: RS485_PORT,
|
||||
resolved_device:
|
||||
"/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_A50285BI-if00-port0",
|
||||
serial_number: "A50285BI",
|
||||
@@ -68,7 +75,24 @@ const PORTS: SerialPortUsage[] = [
|
||||
bcd_device: 1536,
|
||||
matching_integrations: [],
|
||||
present: true,
|
||||
consumers: [],
|
||||
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,
|
||||
},
|
||||
],
|
||||
discovery_flows: [],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -319,6 +319,7 @@ const SCHEMAS: {
|
||||
selector: { config_entry: {} },
|
||||
},
|
||||
duration: { name: "Duration", selector: { duration: {} } },
|
||||
offset: { name: "Offset", selector: { offset: { enable_day: true } } },
|
||||
app: { name: "App", selector: { app: {} } },
|
||||
number_box: {
|
||||
name: "Number Box",
|
||||
|
||||
+5
-6
@@ -110,7 +110,6 @@
|
||||
"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",
|
||||
@@ -163,7 +162,6 @@
|
||||
"@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",
|
||||
@@ -172,7 +170,7 @@
|
||||
"@types/tar": "7.0.87",
|
||||
"@typescript/native": "npm:[email protected]",
|
||||
"@versatiles/style": "5.13.1",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"@vitest/coverage-v8": "5.0.0",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist": "4.28.9",
|
||||
@@ -200,7 +198,7 @@
|
||||
"jszip": "3.10.1",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lightningcss": "1.33.0",
|
||||
"lint-staged": "17.4.1",
|
||||
"lint-staged": "17.5.0",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
@@ -216,8 +214,9 @@
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.69.0",
|
||||
"vite": "8.2.2",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.11",
|
||||
"vitest": "5.0.0",
|
||||
"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"
|
||||
@@ -233,6 +232,6 @@
|
||||
},
|
||||
"packageManager": "[email protected]",
|
||||
"volta": {
|
||||
"node": "24.20.0"
|
||||
"node": "24.21.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
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)
|
||||
);
|
||||
};
|
||||
@@ -1,17 +1,25 @@
|
||||
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: Condition[]): string[] {
|
||||
export function extractMediaQueries(
|
||||
conditions: VisibilityCondition[]
|
||||
): string[] {
|
||||
return conditions.reduce<string[]>((array, c) => {
|
||||
if ("conditions" in c && c.conditions) {
|
||||
array.push(...extractMediaQueries(c.conditions));
|
||||
array.push(...extractMediaQueries(ensureArray(c.conditions)));
|
||||
}
|
||||
if (c.condition === "screen" && c.media_query) {
|
||||
if (
|
||||
"condition" in c &&
|
||||
c.condition === "screen" &&
|
||||
"media_query" in c &&
|
||||
c.media_query
|
||||
) {
|
||||
array.push(c.media_query);
|
||||
}
|
||||
return array;
|
||||
@@ -22,14 +30,15 @@ export function extractMediaQueries(conditions: Condition[]): string[] {
|
||||
* Extract time conditions from conditions recursively
|
||||
*/
|
||||
export function extractTimeConditions(
|
||||
conditions: Condition[]
|
||||
conditions: VisibilityCondition[]
|
||||
): TimeCondition[] {
|
||||
return conditions.reduce<TimeCondition[]>((array, c) => {
|
||||
if ("conditions" in c && c.conditions) {
|
||||
array.push(...extractTimeConditions(c.conditions));
|
||||
array.push(...extractTimeConditions(ensureArray(c.conditions)));
|
||||
}
|
||||
if (c.condition === "time") {
|
||||
array.push(c);
|
||||
if ("condition" in c && c.condition === "time") {
|
||||
// Dashboard `time` is always the client lovelace shape.
|
||||
array.push(c as TimeCondition);
|
||||
}
|
||||
return array;
|
||||
}, []);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { listenMediaQuery } from "../dom/media_query";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type {
|
||||
Condition,
|
||||
ConditionContext,
|
||||
TimeCondition,
|
||||
VisibilityCondition,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
|
||||
import { extractMediaQueries, extractTimeConditions } from "./extract";
|
||||
import { calculateNextTimeUpdate } from "./time-calculator";
|
||||
|
||||
@@ -16,95 +15,65 @@ import { calculateNextTimeUpdate } from "./time-calculator";
|
||||
const MAX_TIMEOUT_DELAY = 2147483647;
|
||||
|
||||
/**
|
||||
* Helper to setup media query listeners for conditional visibility
|
||||
* Fire onChange at the next time-condition boundary, then reschedule.
|
||||
* Delays past the setTimeout max are capped and retried without firing.
|
||||
*/
|
||||
export function setupMediaQueryListeners(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
function scheduleTimeBoundaryListener(
|
||||
getHass: () => HomeAssistant,
|
||||
timeCondition: Omit<TimeCondition, "condition">,
|
||||
addListener: (unsub: () => void) => void,
|
||||
onUpdate: (conditionsMet: boolean) => void,
|
||||
getContext?: () => ConditionContext
|
||||
onBoundary: () => void
|
||||
): void {
|
||||
const mediaQueries = extractMediaQueries(conditions);
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
if (mediaQueries.length === 0) return;
|
||||
const scheduleUpdate = () => {
|
||||
// Read hass here so a timezone change applies at the next boundary.
|
||||
const delay = calculateNextTimeUpdate(getHass(), timeCondition);
|
||||
|
||||
// Optimization for single media query
|
||||
const hasOnlyMediaQuery =
|
||||
conditions.length === 1 &&
|
||||
conditions[0].condition === "screen" &&
|
||||
!!conditions[0].media_query;
|
||||
if (delay === undefined) return;
|
||||
|
||||
mediaQueries.forEach((mediaQuery) => {
|
||||
const unsub = listenMediaQuery(mediaQuery, (matches) => {
|
||||
if (hasOnlyMediaQuery) {
|
||||
onUpdate(matches);
|
||||
} else {
|
||||
const context = getContext?.() ?? {};
|
||||
const conditionsMet = checkConditionsMet(conditions, hass, context);
|
||||
onUpdate(conditionsMet);
|
||||
// Cap delay to prevent setTimeout overflow
|
||||
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (delay <= MAX_TIMEOUT_DELAY) {
|
||||
onBoundary();
|
||||
}
|
||||
});
|
||||
addListener(unsub);
|
||||
scheduleUpdate();
|
||||
}, cappedDelay);
|
||||
};
|
||||
|
||||
// Register cleanup function once, outside of scheduleUpdate
|
||||
addListener(() => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
scheduleUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to setup time-based listeners for conditional visibility
|
||||
* Observe the client-evaluated parts of a condition tree — `screen` media
|
||||
* queries and `time` boundaries — and invoke `onChange` whenever one of them
|
||||
* could have flipped.
|
||||
*
|
||||
* This does not evaluate the conditions itself: the caller recombines client
|
||||
* and server results on notification. Used by `ConditionEvaluatorController`,
|
||||
* which merges these client signals with the results of `subscribe_condition`
|
||||
* subscriptions.
|
||||
*/
|
||||
export function setupTimeListeners(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
export function observeConditionChanges(
|
||||
conditions: VisibilityCondition[],
|
||||
getHass: () => HomeAssistant,
|
||||
addListener: (unsub: () => void) => void,
|
||||
onUpdate: (conditionsMet: boolean) => void,
|
||||
getContext?: () => ConditionContext
|
||||
onChange: () => void
|
||||
): void {
|
||||
const timeConditions = extractTimeConditions(conditions);
|
||||
extractMediaQueries(conditions).forEach((mediaQuery) => {
|
||||
addListener(listenMediaQuery(mediaQuery, () => onChange()));
|
||||
});
|
||||
|
||||
if (timeConditions.length === 0) return;
|
||||
|
||||
timeConditions.forEach((timeCondition) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const scheduleUpdate = () => {
|
||||
const delay = calculateNextTimeUpdate(hass, timeCondition);
|
||||
|
||||
if (delay === undefined) return;
|
||||
|
||||
// Cap delay to prevent setTimeout overflow
|
||||
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (delay <= MAX_TIMEOUT_DELAY) {
|
||||
const context = getContext?.() ?? {};
|
||||
const conditionsMet = checkConditionsMet(conditions, hass, context);
|
||||
onUpdate(conditionsMet);
|
||||
}
|
||||
scheduleUpdate();
|
||||
}, cappedDelay);
|
||||
};
|
||||
|
||||
// Register cleanup function once, outside of scheduleUpdate
|
||||
addListener(() => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
scheduleUpdate();
|
||||
extractTimeConditions(conditions).forEach((timeCondition) => {
|
||||
scheduleTimeBoundaryListener(getHass, timeCondition, addListener, onChange);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up all condition listeners (media query, time) for conditional visibility.
|
||||
*/
|
||||
export function setupConditionListeners(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
addListener: (unsub: () => void) => void,
|
||||
onUpdate: (conditionsMet: boolean) => void,
|
||||
getContext?: () => ConditionContext
|
||||
): void {
|
||||
setupMediaQueryListeners(conditions, hass, addListener, onUpdate, getContext);
|
||||
setupTimeListeners(conditions, hass, addListener, onUpdate, getContext);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
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),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,304 @@
|
||||
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 };
|
||||
};
|
||||
@@ -0,0 +1,353 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import type {
|
||||
Condition,
|
||||
ConditionContext,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { setupConditionListeners } from "../condition/listeners";
|
||||
|
||||
/**
|
||||
* Reactive controller that manages the media-query and time-based listeners
|
||||
* needed to keep a set of lovelace visibility conditions evaluated live.
|
||||
*
|
||||
* The host is responsible for the actual evaluation (e.g. computing visible /
|
||||
* hidden / invalid state); the controller only triggers it via the supplied
|
||||
* `onUpdate` callback when something the conditions depend on changes. Call
|
||||
* `setup()` whenever the conditions change; the controller clears previous
|
||||
* listeners and re-subscribes. Listeners are automatically released when the
|
||||
* host disconnects.
|
||||
*/
|
||||
export class ConditionListenersController implements ReactiveController {
|
||||
private _unsubs: (() => void)[] = [];
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this.clear();
|
||||
}
|
||||
|
||||
public setup(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
onUpdate: () => void,
|
||||
getContext?: () => ConditionContext
|
||||
): void {
|
||||
this.clear();
|
||||
if (!conditions.length) {
|
||||
return;
|
||||
}
|
||||
setupConditionListeners(
|
||||
conditions,
|
||||
hass,
|
||||
(unsub) => this._unsubs.push(unsub),
|
||||
() => onUpdate(),
|
||||
getContext
|
||||
);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
for (const unsub of this._unsubs) {
|
||||
unsub();
|
||||
}
|
||||
this._unsubs = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { HaDurationData } from "../../components/ha-duration-input";
|
||||
|
||||
export const durationValueToData = (
|
||||
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)));
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -12,13 +12,24 @@ 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: "entity" | "device" | "area" | "floor";
|
||||
type: EntityNameType;
|
||||
}
|
||||
| {
|
||||
type: "text";
|
||||
@@ -91,7 +102,7 @@ export const computeEntityNameList = (
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"]
|
||||
): (string | undefined)[] => {
|
||||
const { device, area, floor } = getEntityContext(
|
||||
const { device, parentDevice, area, floor } = getEntityContext(
|
||||
stateObj,
|
||||
entities,
|
||||
devices,
|
||||
@@ -105,6 +116,8 @@ 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":
|
||||
@@ -136,14 +149,20 @@ export const computeEntityPickerDisplay = (
|
||||
>,
|
||||
stateObj: HassEntity
|
||||
): EntityPickerDisplay => {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
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 isRTL = computeRTL(
|
||||
hass.language,
|
||||
@@ -152,7 +171,7 @@ export const computeEntityPickerDisplay = (
|
||||
|
||||
const primary = entityName || deviceName || stateObj.entity_id;
|
||||
const secondary =
|
||||
[areaName, entityName ? deviceName : undefined]
|
||||
[areaName, parentDeviceName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ") || undefined;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getDeviceAreaId } from "./get_device_context";
|
||||
interface EntityContext {
|
||||
entity: EntityRegistryDisplayEntry | null;
|
||||
device: DeviceRegistryEntry | null;
|
||||
parentDevice: DeviceRegistryEntry | null;
|
||||
area: AreaRegistryEntry | null;
|
||||
floor: FloorRegistryEntry | null;
|
||||
}
|
||||
@@ -31,6 +32,7 @@ export const getEntityContext = (
|
||||
return {
|
||||
entity: null,
|
||||
device: null,
|
||||
parentDevice: null,
|
||||
area: null,
|
||||
floor: null,
|
||||
};
|
||||
@@ -65,6 +67,9 @@ 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;
|
||||
@@ -74,6 +79,7 @@ export const getEntityEntryContext = (
|
||||
return {
|
||||
entity: entity,
|
||||
device: device || null,
|
||||
parentDevice: parentDevice || null,
|
||||
area: area || null,
|
||||
floor: floor || null,
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
const VECTOR_STYLES = {
|
||||
export 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.
|
||||
const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
|
||||
export 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.
|
||||
const CONTEXT_RESTORE_GRACE = 2000;
|
||||
const RECOVERY_THROTTLE = 30000;
|
||||
export const CONTEXT_RESTORE_GRACE = 2000;
|
||||
export 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.
|
||||
const supportsWebGL2 = (): boolean => {
|
||||
export 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;
|
||||
};
|
||||
|
||||
const loadStyle = async (url: string): Promise<StyleSpecification> => {
|
||||
export const loadStyle = async (url: string): Promise<StyleSpecification> => {
|
||||
const style: StyleSpecification = await (await fetch(url)).json();
|
||||
|
||||
if (__DEMO__) {
|
||||
@@ -111,7 +111,7 @@ const loadStyle = async (url: string): Promise<StyleSpecification> => {
|
||||
|
||||
// Global to MapLibre, and it throws when set twice.
|
||||
let rtlTextPluginRequested = false;
|
||||
const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
|
||||
export const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
|
||||
if (rtlTextPluginRequested) {
|
||||
return;
|
||||
}
|
||||
@@ -303,9 +303,11 @@ export const createBaseLayer = async (
|
||||
leaflet: LeafletModuleType,
|
||||
map: LeafletMap,
|
||||
darkMode: boolean,
|
||||
token: string | undefined
|
||||
token: string | undefined,
|
||||
// Skip the vector layer, e.g. after a permanent WebGL context loss
|
||||
rasterOnly = false
|
||||
): Promise<MapBaseLayer> => {
|
||||
if (supportsWebGL2()) {
|
||||
if (!rasterOnly && supportsWebGL2()) {
|
||||
let vectorLayer: MapBaseLayer | undefined;
|
||||
try {
|
||||
const [{ maplibreGL: createLayer }, maplibre] = await Promise.all([
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/** 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);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,348 @@
|
||||
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
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 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],
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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];
|
||||
};
|
||||
@@ -31,8 +31,6 @@ export type FormatEntityAttributeNameFunc = (
|
||||
attribute: string
|
||||
) => string;
|
||||
|
||||
export type EntityNameType = "entity" | "device" | "area" | "floor";
|
||||
|
||||
export type FormatEntityNameFunc = (
|
||||
stateObj: HassEntity,
|
||||
name: EntityNameItem | EntityNameItem[],
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ 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 {
|
||||
@@ -55,6 +57,9 @@ 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;
|
||||
@@ -84,7 +89,11 @@ export class HaSankeyChart extends LitElement {
|
||||
|
||||
return html`<ha-chart-base
|
||||
.hass=${this.hass}
|
||||
.data=${this._createData(this.data, this._sizeController.value?.width)}
|
||||
.data=${this._createData(
|
||||
this.data,
|
||||
this._sizeController.value?.width,
|
||||
this.showValues
|
||||
)}
|
||||
.options=${options}
|
||||
height="100%"
|
||||
.extraComponents=${[SankeyChart]}
|
||||
@@ -142,7 +151,11 @@ export class HaSankeyChart extends LitElement {
|
||||
}
|
||||
};
|
||||
|
||||
private _createData = memoizeOne((data: SankeyChartData, width = 0) => {
|
||||
private _computeData = (
|
||||
data: SankeyChartData,
|
||||
width = 0,
|
||||
showValues = false
|
||||
) => {
|
||||
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>();
|
||||
@@ -183,6 +196,10 @@ 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",
|
||||
@@ -209,29 +226,48 @@ export class HaSankeyChart extends LitElement {
|
||||
layoutIterations: 0,
|
||||
animationDuration: 500,
|
||||
label: {
|
||||
formatter: (params) =>
|
||||
data.nodes.find((node) => node.id === (params.data as Node).id)
|
||||
?.label ?? (params.data as Node).id,
|
||||
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}`;
|
||||
},
|
||||
position: this.vertical ? "bottom" : "right",
|
||||
distance: LABEL_DISTANCE,
|
||||
minMargin: 5,
|
||||
minMargin: LABEL_MIN_MARGIN,
|
||||
overflow: "break",
|
||||
},
|
||||
labelLayout: (params) => {
|
||||
if (this.vertical) {
|
||||
// reduce the label font size so the longest word fits on one line
|
||||
const longestWord = params.text
|
||||
.split(" ")
|
||||
.reduce(
|
||||
(longest, current) =>
|
||||
longest.length > current.length ? longest : current,
|
||||
""
|
||||
);
|
||||
const wordWidth = measureTextWidth(longestWord, FONT_SIZE);
|
||||
.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;
|
||||
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,
|
||||
(availableWidth / wordWidth) * FONT_SIZE
|
||||
(wrapWidth / wordWidth) * FONT_SIZE
|
||||
);
|
||||
return {
|
||||
fontSize: fontSize > 1 ? fontSize : 0,
|
||||
@@ -254,14 +290,16 @@ export class HaSankeyChart extends LitElement {
|
||||
};
|
||||
},
|
||||
top: this.vertical ? 0 : OVERFLOW_MARGIN,
|
||||
bottom: this.vertical ? 25 : OVERFLOW_MARGIN,
|
||||
bottom: this.vertical ? verticalBottom : 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,6 +23,8 @@ 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")
|
||||
@@ -41,6 +43,11 @@ 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.
|
||||
@property({ attribute: "inside-labels", type: Boolean })
|
||||
public insideLabels = false;
|
||||
|
||||
@property({ attribute: "click-for-more-info", type: Boolean })
|
||||
public clickForMoreInfo = true;
|
||||
|
||||
@@ -70,7 +77,11 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
<ha-chart-base
|
||||
.hass=${this.hass}
|
||||
.options=${this._chartOptions}
|
||||
.height=${`${this.data.length * ROW_HEIGHT + GRID_BOTTOM}px`}
|
||||
.height=${`${
|
||||
this.data.length *
|
||||
(this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) +
|
||||
GRID_BOTTOM
|
||||
}px`}
|
||||
.data=${this._chartData as HaECSeries}
|
||||
small-controls
|
||||
@chart-click=${this._handleChartClick}
|
||||
@@ -185,6 +196,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
changedProps.has("startTime") ||
|
||||
changedProps.has("endTime") ||
|
||||
changedProps.has("showNames") ||
|
||||
changedProps.has("insideLabels") ||
|
||||
changedProps.has("paddingYAxis") ||
|
||||
changedProps.has("_yWidth")
|
||||
) {
|
||||
@@ -196,9 +208,11 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
const narrow = this.narrow;
|
||||
const showNames = this.chunked || this.showNames;
|
||||
const maxInternalLabelWidth = narrow ? 105 : 185;
|
||||
const labelWidth = showNames
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const insideLabels = this.insideLabels;
|
||||
const labelWidth =
|
||||
showNames && !insideLabels
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const labelMargin = 5;
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
@@ -227,31 +241,47 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
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,
|
||||
});
|
||||
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,
|
||||
}
|
||||
return label;
|
||||
},
|
||||
hideOverlap: true,
|
||||
},
|
||||
: {
|
||||
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,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: 10,
|
||||
|
||||
@@ -79,6 +79,11 @@ 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.
|
||||
@property({ attribute: "inside-labels", type: Boolean })
|
||||
public insideLabels = false;
|
||||
|
||||
@property({ attribute: "click-for-more-info", type: Boolean })
|
||||
public clickForMoreInfo = true;
|
||||
|
||||
@@ -227,6 +232,7 @@ 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}
|
||||
@@ -444,6 +450,10 @@ 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%;
|
||||
|
||||
@@ -7,9 +7,12 @@ 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 type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
|
||||
import {
|
||||
ENTITY_NAME_TYPES,
|
||||
type EntityNameItem,
|
||||
type EntityNameType,
|
||||
} 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";
|
||||
@@ -35,9 +38,7 @@ const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
const KNOWN_TYPES = new Set(["entity", "device", "area", "floor"]);
|
||||
|
||||
const UNIQUE_TYPES = new Set(["entity", "device", "area", "floor"]);
|
||||
const KNOWN_TYPES = new Set<string>(ENTITY_NAME_TYPES);
|
||||
|
||||
const formatOptionValue = (item: EntityNameItem) => {
|
||||
if (item.type === "text" && item.text) {
|
||||
@@ -170,6 +171,7 @@ 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"
|
||||
@@ -387,6 +389,7 @@ 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;
|
||||
@@ -399,8 +402,8 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
const types = this._validTypes(entityId);
|
||||
|
||||
const items = (
|
||||
["entity", "device", "area", "floor"] as const
|
||||
const items = ENTITY_NAME_TYPES.filter(
|
||||
(name) => name !== "parent_device" || types.has(name)
|
||||
).map<PickerComboBoxItem>((name) => {
|
||||
const stateObj = this.hass.states[entityId];
|
||||
const isValid = types.has(name);
|
||||
@@ -464,7 +467,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
const excludedValues = new Set(
|
||||
this._items
|
||||
.filter((item) => UNIQUE_TYPES.has(item.type))
|
||||
.filter((item) => KNOWN_TYPES.has(item.type))
|
||||
.map((item) => formatOptionValue(item))
|
||||
);
|
||||
|
||||
|
||||
@@ -178,6 +178,17 @@ 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",
|
||||
|
||||
@@ -52,6 +52,7 @@ 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 },
|
||||
@@ -292,17 +293,27 @@ export class HaStatisticPicker extends LitElement {
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
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 primary = entityName || deviceName || id;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
@@ -318,6 +329,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
},
|
||||
@@ -395,14 +407,20 @@ export class HaStatisticPicker extends LitElement {
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
|
||||
if (stateObj) {
|
||||
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 [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 isRTL = computeRTL(
|
||||
this.hass.language,
|
||||
@@ -410,7 +428,11 @@ export class HaStatisticPicker extends LitElement {
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || statisticId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
@@ -427,6 +449,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
statisticId,
|
||||
|
||||
@@ -50,7 +50,11 @@ export class HaAnalytics extends LitElement {
|
||||
.preference=${"base"}
|
||||
.disabled=${loading}
|
||||
name="base"
|
||||
></ha-switch>
|
||||
>
|
||||
${this.localize(
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.title`
|
||||
)}
|
||||
</ha-switch>
|
||||
</ha-row-item>
|
||||
${ADDITIONAL_PREFERENCES.map(
|
||||
(preference) => html`
|
||||
@@ -72,7 +76,11 @@ export class HaAnalytics extends LitElement {
|
||||
.checked=${!!this.analytics?.preferences[preference]}
|
||||
.preference=${preference}
|
||||
name=${preference}
|
||||
></ha-switch>
|
||||
>
|
||||
${this.localize(
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.title`
|
||||
)}
|
||||
</ha-switch>
|
||||
${
|
||||
baseEnabled
|
||||
? nothing
|
||||
@@ -106,7 +114,11 @@ export class HaAnalytics extends LitElement {
|
||||
.preference=${"diagnostics"}
|
||||
.disabled=${loading}
|
||||
name="diagnostics"
|
||||
></ha-switch>
|
||||
>
|
||||
${this.localize(
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.title`
|
||||
)}
|
||||
</ha-switch>
|
||||
</ha-row-item>
|
||||
`;
|
||||
}
|
||||
@@ -143,6 +155,19 @@ 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;
|
||||
}
|
||||
|
||||
@@ -197,17 +197,27 @@ export class HaAreaControlsPicker extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
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 [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 primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
|
||||
@@ -761,6 +761,9 @@ 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)
|
||||
@@ -795,6 +798,15 @@ 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"),
|
||||
|
||||
@@ -77,9 +77,8 @@ export class HaFilterDevices extends LitElement {
|
||||
super.willUpdate(properties);
|
||||
|
||||
if (
|
||||
properties.has("type") ||
|
||||
(properties.has("value") &&
|
||||
!deepEqual(this.value, properties.get("value")))
|
||||
properties.has("value") &&
|
||||
!deepEqual(this.value, properties.get("value"))
|
||||
) {
|
||||
this._findRelated();
|
||||
}
|
||||
@@ -125,7 +124,7 @@ export class HaFilterDevices extends LitElement {
|
||||
this._states,
|
||||
this._i18n.locale.language
|
||||
)}
|
||||
.rowRenderer=${this._renderItem(this.value)}
|
||||
.rowRenderer=${this._renderItem}
|
||||
@ha-list-item-selected=${this._handleAdded}
|
||||
@ha-list-item-deselected=${this._handleRemoved}
|
||||
></ha-list-selectable-virtualized>
|
||||
@@ -135,20 +134,18 @@ export class HaFilterDevices extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
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 _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 _handleAdded(ev: CustomEvent<number>) {
|
||||
this.value = [
|
||||
@@ -214,11 +211,9 @@ export class HaFilterDevices extends LitElement {
|
||||
);
|
||||
|
||||
private async _findRelated() {
|
||||
const value = this.value;
|
||||
const type = this.type;
|
||||
const relatedPromises: Promise<RelatedResult>[] = [];
|
||||
|
||||
if (!value?.length) {
|
||||
if (!this.value?.length) {
|
||||
this.value = [];
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: [],
|
||||
@@ -227,25 +222,25 @@ export class HaFilterDevices extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const deviceId of value) {
|
||||
if (type) {
|
||||
const value: string[] = [];
|
||||
|
||||
for (const deviceId of this.value) {
|
||||
value.push(deviceId);
|
||||
if (this.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 (type && result[type]) {
|
||||
result[type].forEach((item) => items.add(item));
|
||||
if (result[this.type!]) {
|
||||
result[this.type!]!.forEach((item) => items.add(item));
|
||||
}
|
||||
}
|
||||
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value,
|
||||
items: type ? items : undefined,
|
||||
items: this.type ? items : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -79,9 +79,8 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
super.willUpdate(properties);
|
||||
|
||||
if (
|
||||
properties.has("type") ||
|
||||
(properties.has("value") &&
|
||||
!deepEqual(this.value, properties.get("value")))
|
||||
properties.has("value") &&
|
||||
!deepEqual(this.value, properties.get("value"))
|
||||
) {
|
||||
this._findRelated();
|
||||
}
|
||||
@@ -279,11 +278,12 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
);
|
||||
|
||||
private async _findRelated() {
|
||||
const value = this.value;
|
||||
const type = this.type;
|
||||
const relatedPromises: Promise<RelatedResult>[] = [];
|
||||
|
||||
if (!value || (!value.areas?.length && !value.floors?.length)) {
|
||||
if (
|
||||
!this.value ||
|
||||
(!this.value.areas?.length && !this.value.floors?.length)
|
||||
) {
|
||||
this.value = {};
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: {},
|
||||
@@ -292,36 +292,33 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.areas) {
|
||||
for (const areaId of value.areas) {
|
||||
if (type) {
|
||||
if (this.value.areas) {
|
||||
for (const areaId of this.value.areas) {
|
||||
if (this.type) {
|
||||
relatedPromises.push(findRelated(this._api, "area", areaId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (value.floors) {
|
||||
for (const floorId of value.floors) {
|
||||
if (type) {
|
||||
if (this.value.floors) {
|
||||
for (const floorId of this.value.floors) {
|
||||
if (this.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 (type && result[type]) {
|
||||
result[type].forEach((item) => items.add(item));
|
||||
if (result[this.type!]) {
|
||||
result[this.type!]!.forEach((item) => items.add(item));
|
||||
}
|
||||
}
|
||||
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value,
|
||||
items: type ? items : undefined,
|
||||
value: this.value,
|
||||
items: this.type ? items : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ const SELECTOR_FALLBACK_VALUES = {
|
||||
number: (selector) => selector.number?.min ?? 0,
|
||||
numeric_threshold: undefined,
|
||||
object: undefined,
|
||||
offset: undefined,
|
||||
period: undefined,
|
||||
qr_code: undefined,
|
||||
select: undefined,
|
||||
|
||||
@@ -74,6 +74,7 @@ const SELECTOR_INITIAL_VALUES = {
|
||||
};
|
||||
},
|
||||
object: (selector) => (selector.object?.multiple ? [] : ""),
|
||||
offset: () => ({ type: "none" }),
|
||||
period: undefined,
|
||||
qr_code: undefined,
|
||||
select: (selector) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
|
||||
import type { DurationSelector } from "../../data/selector";
|
||||
import "../ha-duration-input";
|
||||
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
|
||||
@@ -26,35 +27,7 @@ export class HaTimeDuration extends LitElement {
|
||||
return this._input?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
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)));
|
||||
|
||||
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;
|
||||
}
|
||||
);
|
||||
private _data = memoizeOne(durationValueToData);
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
mdiClockMinusOutline,
|
||||
mdiClockOutline,
|
||||
mdiClockPlusOutline,
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import type {
|
||||
OffsetSelector,
|
||||
OffsetSelectorValue,
|
||||
OffsetType,
|
||||
} from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-duration-input";
|
||||
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
|
||||
import "../ha-input-helper-text";
|
||||
import "../ha-select";
|
||||
|
||||
const OFFSET_TYPES: { value: OffsetType; iconPath: string }[] = [
|
||||
{ value: "none", iconPath: mdiClockOutline },
|
||||
{ value: "before", iconPath: mdiClockMinusOutline },
|
||||
{ value: "after", iconPath: mdiClockPlusOutline },
|
||||
];
|
||||
|
||||
const DEFAULT_DURATION: HaDurationData = { hours: 0, minutes: 0, seconds: 0 };
|
||||
|
||||
const isOffsetValue = (value: unknown): value is OffsetSelectorValue =>
|
||||
typeof value === "object" && value !== null && "type" in value;
|
||||
|
||||
@customElement("ha-selector-offset")
|
||||
export class HaOffsetSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: OffsetSelector;
|
||||
|
||||
@property({ attribute: false }) public value?: OffsetSelectorValue;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@query("ha-duration-input", true) private _durationInput?: HaDurationInput;
|
||||
|
||||
public reportValidity(): boolean {
|
||||
return this._durationInput?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
private get _value(): OffsetSelectorValue | undefined {
|
||||
return isOffsetValue(this.value) ? this.value : undefined;
|
||||
}
|
||||
|
||||
private _duration = memoizeOne(durationValueToData);
|
||||
|
||||
private _typeOptions = memoizeOne((localize: LocalizeFunc) =>
|
||||
OFFSET_TYPES.map(({ value, iconPath }) => ({
|
||||
value,
|
||||
iconPath,
|
||||
label: localize(`ui.components.selectors.offset.${value}`),
|
||||
}))
|
||||
);
|
||||
|
||||
protected render() {
|
||||
const type = this._value?.type ?? "none";
|
||||
return html`
|
||||
<div class="container">
|
||||
${
|
||||
this.label
|
||||
? html`<label>${this.label}${this.required ? "*" : ""}</label>`
|
||||
: nothing
|
||||
}
|
||||
<div class="inputs">
|
||||
<ha-select
|
||||
.value=${type}
|
||||
.options=${this._typeOptions(this.hass.localize)}
|
||||
.disabled=${this.disabled}
|
||||
@selected=${this._typeChanged}
|
||||
></ha-select>
|
||||
${
|
||||
type !== "none"
|
||||
? html`<div class="value-row">
|
||||
<span class="value-label"
|
||||
>${this.hass.localize(
|
||||
"ui.components.selectors.offset.duration"
|
||||
)}${this.required ? "*" : ""}</span
|
||||
>
|
||||
<ha-duration-input
|
||||
.data=${this._duration(this._value?.duration)}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.enableDay=${this.selector.offset?.enable_day}
|
||||
.enableMillisecond=${
|
||||
this.selector.offset?.enable_millisecond
|
||||
}
|
||||
@value-changed=${this._durationChanged}
|
||||
></ha-duration-input>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _durationChanged(ev: CustomEvent<{ value?: HaDurationData }>) {
|
||||
ev.stopPropagation();
|
||||
const type = this._value?.type;
|
||||
if (!type || type === "none") {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
value: { type, duration: ev.detail.value ?? DEFAULT_DURATION },
|
||||
});
|
||||
}
|
||||
|
||||
private _typeChanged(ev: CustomEvent<{ value?: string }>) {
|
||||
ev.stopPropagation();
|
||||
const type = ev.detail.value as OffsetType | undefined;
|
||||
if (!type || type === this._value?.type) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
value:
|
||||
type === "none"
|
||||
? { type }
|
||||
: {
|
||||
type,
|
||||
duration:
|
||||
this._duration(this._value?.duration) ?? DEFAULT_DURATION,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
margin-bottom: 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 {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-selector-offset": HaOffsetSelector;
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ const LOAD_ELEMENTS = {
|
||||
number: () => import("./ha-selector-number"),
|
||||
numeric_threshold: () => import("./ha-selector-numeric-threshold"),
|
||||
object: () => import("./ha-selector-object"),
|
||||
offset: () => import("./ha-selector-offset"),
|
||||
period: () => import("./ha-selector-period"),
|
||||
qr_code: () => import("./ha-selector-qr-code"),
|
||||
select: () => import("./ha-selector-select"),
|
||||
|
||||
@@ -192,14 +192,7 @@ export class HaListVirtualized extends HaListBase {
|
||||
this.rangeStart = ev.first;
|
||||
this.rangeEnd = ev.last;
|
||||
|
||||
try {
|
||||
await this.virtualizerElement?.layoutComplete;
|
||||
} catch (err) {
|
||||
if (err === "disconnected") {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await this.virtualizerElement?.layoutComplete;
|
||||
this._applySetSize();
|
||||
|
||||
if (!this.virtualizerElement) {
|
||||
|
||||
@@ -58,6 +58,24 @@ 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) {
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import type {
|
||||
Circle,
|
||||
DivIcon,
|
||||
DragEndEvent,
|
||||
LatLng,
|
||||
LatLngExpression,
|
||||
Marker,
|
||||
MarkerOptions,
|
||||
} from "leaflet";
|
||||
import { consume } from "@lit/context";
|
||||
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 { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
|
||||
import type { ThemeMode } from "../../types";
|
||||
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 "../ha-input-helper-text";
|
||||
import "./ha-map";
|
||||
import type { HaMap } from "./ha-map";
|
||||
import type { HaMap, HaMapEditableLocation } from "./ha-map";
|
||||
import type { HaIcon } from "../ha-icon";
|
||||
import type { HaSvgIcon } from "../ha-svg-icon";
|
||||
|
||||
@@ -41,8 +38,16 @@ 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[];
|
||||
@@ -59,35 +64,20 @@ 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;
|
||||
|
||||
private Leaflet?: LeafletModuleType;
|
||||
@state() private _editingAvailable = true;
|
||||
|
||||
// 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());
|
||||
})
|
||||
);
|
||||
}
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: HomeAssistantInternationalization;
|
||||
|
||||
public fitMap(options?: { zoom?: number; pad?: number }): void {
|
||||
this.map.fitMap(options);
|
||||
}
|
||||
|
||||
public fitBounds(
|
||||
boundingbox: LatLngExpression[],
|
||||
boundingbox: MapLatLng[],
|
||||
options?: { zoom?: number; pad?: number }
|
||||
) {
|
||||
this.map.fitBounds(boundingbox, options);
|
||||
@@ -97,42 +87,50 @@ export class HaLocationsEditor extends LitElement {
|
||||
id: string,
|
||||
options?: { zoom?: number }
|
||||
): Promise<void> {
|
||||
if (!this.Leaflet) {
|
||||
await this._loadPromise;
|
||||
}
|
||||
if (!this.map.leafletMap || !this._locationMarkers) {
|
||||
await this.updateComplete;
|
||||
const location = this.locations?.find((loc) => loc.id === id);
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
const marker = this._locationMarkers[id];
|
||||
if (!marker) {
|
||||
return;
|
||||
}
|
||||
if ("getBounds" in marker) {
|
||||
this.map.leafletMap.fitBounds(marker.getBounds());
|
||||
(marker as Circle).bringToFront();
|
||||
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,
|
||||
});
|
||||
} else {
|
||||
const circle = this._circles[id];
|
||||
if (circle) {
|
||||
this.map.leafletMap.fitBounds(circle.getBounds());
|
||||
} else {
|
||||
this.map.leafletMap.setView(
|
||||
marker.getLatLng(),
|
||||
options?.zoom || this.zoom
|
||||
);
|
||||
}
|
||||
this.map.setView(center, options?.zoom || this.zoom);
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<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>
|
||||
<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>
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
@@ -141,63 +139,102 @@ export class HaLocationsEditor extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
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));
|
||||
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);
|
||||
}
|
||||
}
|
||||
return layers;
|
||||
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,
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProps);
|
||||
// Reused while unchanged, so ha-map moves markers instead of rebuilding them
|
||||
private _elements = new Map<string, { key: string; element?: HTMLElement }>();
|
||||
|
||||
// Still loading.
|
||||
if (!this.Leaflet) {
|
||||
return;
|
||||
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;
|
||||
}
|
||||
const element = this._createIcon(location);
|
||||
this._elements.set(location.id, { key, element });
|
||||
return element;
|
||||
}
|
||||
|
||||
if (changedProps.has("locations")) {
|
||||
this._updateMarkers();
|
||||
private _createIcon(location: MarkerLocation): HTMLElement | undefined {
|
||||
if (!location.icon && !location.iconPath) {
|
||||
return undefined;
|
||||
}
|
||||
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 {
|
||||
// Still loading.
|
||||
if (!this.Leaflet) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (changedProps.has("locations")) {
|
||||
const oldLocations = changedProps.get("locations");
|
||||
fireEvent(this, "markers-updated");
|
||||
|
||||
// Follow a location that was edited out of view
|
||||
const oldLocations = changedProps.get("locations") as
|
||||
MarkerLocation[] | undefined;
|
||||
const movedLocations = this.locations?.filter(
|
||||
(loc, idx) =>
|
||||
!oldLocations[idx] ||
|
||||
!oldLocations?.[idx] ||
|
||||
((loc.latitude !== oldLocations[idx].latitude ||
|
||||
loc.longitude !== oldLocations[idx].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 }))
|
||||
this.map.containsLocation([
|
||||
oldLocations[idx].latitude,
|
||||
oldLocations[idx].longitude,
|
||||
]) &&
|
||||
!this.map.containsLocation([loc.latitude, loc.longitude]))
|
||||
);
|
||||
if (movedLocations?.length === 1) {
|
||||
this.map.leafletMap?.panTo({
|
||||
lat: movedLocations[0].latitude,
|
||||
lng: movedLocations[0].longitude,
|
||||
});
|
||||
this.map.panTo([
|
||||
movedLocations[0].latitude,
|
||||
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.
|
||||
@@ -206,40 +243,37 @@ export class HaLocationsEditor extends LitElement {
|
||||
return longitude;
|
||||
}
|
||||
|
||||
private _updateLocation(ev: DragEndEvent) {
|
||||
const marker = ev.target;
|
||||
const latlng: LatLng = marker.getLatLng();
|
||||
const location: [number, number] = [
|
||||
latlng.lat,
|
||||
this._normalizeLongitude(latlng.lng),
|
||||
];
|
||||
private _locationMoved(
|
||||
ev: HASSDomEvent<{ id: string; location: MapLatLng }>
|
||||
) {
|
||||
const [latitude, longitude] = ev.detail.location;
|
||||
fireEvent(
|
||||
this,
|
||||
"location-updated",
|
||||
{ id: marker.id, location },
|
||||
{
|
||||
id: ev.detail.id,
|
||||
location: [latitude, this._normalizeLongitude(longitude)],
|
||||
},
|
||||
{ bubbles: false }
|
||||
);
|
||||
}
|
||||
|
||||
private _updateRadius(ev: DragEndEvent) {
|
||||
const marker = ev.target;
|
||||
const circle = this._locationMarkers![marker.id] as Circle;
|
||||
private _radiusChanged(ev: HASSDomEvent<{ id: string; radius: number }>) {
|
||||
fireEvent(
|
||||
this,
|
||||
"radius-updated",
|
||||
{ id: marker.id, radius: circle.getRadius() },
|
||||
{ id: ev.detail.id, radius: ev.detail.radius },
|
||||
{ bubbles: false }
|
||||
);
|
||||
}
|
||||
|
||||
private _markerClicked(ev: DragEndEvent) {
|
||||
const marker = ev.target;
|
||||
fireEvent(this, "marker-clicked", { id: marker.id }, { bubbles: false });
|
||||
private _markerClicked(ev: HASSDomEvent<{ id: string }>) {
|
||||
fireEvent(this, "marker-clicked", { id: ev.detail.id }, { bubbles: false });
|
||||
}
|
||||
|
||||
private _mapClicked(ev) {
|
||||
if (this.pinOnClick && this._locationMarkers) {
|
||||
const id = Object.keys(this._locationMarkers)[0];
|
||||
private _mapClicked(ev: HASSDomEvent<{ location: [number, number] }>) {
|
||||
if (this.pinOnClick && this.locations?.length) {
|
||||
const id = this.locations[0].id;
|
||||
const location: [number, number] = [
|
||||
ev.detail.location[0],
|
||||
this._normalizeLongitude(ev.detail.location[1]),
|
||||
@@ -248,136 +282,28 @@ 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.leafletMap?.panTo({ lat: location[0], lng: location[1] });
|
||||
this.map.panTo(location);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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`
|
||||
.map {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
+733
-336
File diff suppressed because it is too large
Load Diff
@@ -129,21 +129,31 @@ export class HaMediaPlayerPicker extends LitElement {
|
||||
.filter(this._filterPlayerEntities)
|
||||
.map<MediaPlayerComboBoxItem>((stateObj) => {
|
||||
const friendlyName = computeStateName(stateObj);
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this._entities,
|
||||
this._devices,
|
||||
this._areas,
|
||||
this._floors
|
||||
);
|
||||
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 entityId = stateObj.entity_id;
|
||||
const domainName = domainToName(
|
||||
this._i18n.localize,
|
||||
computeDomain(entityId)
|
||||
);
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
@@ -157,6 +167,7 @@ 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,17 +62,27 @@ class HaMediaPlayerToggle extends LitElement {
|
||||
isRTL: boolean,
|
||||
stateObj: HassEntity
|
||||
) => {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ 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 {
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
} from "../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { computeEntityName } from "../../common/entity/compute_entity_name";
|
||||
import { getDeviceArea } 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";
|
||||
@@ -132,10 +132,35 @@ 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() {
|
||||
@@ -674,7 +699,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _itemData = memoizeOne((type: TargetType, item: string) => {
|
||||
private _itemData(type: TargetType, item: string) {
|
||||
if (type === "floor") {
|
||||
const floor: FloorRegistryEntry | undefined = this.hass.floors?.[item];
|
||||
return {
|
||||
@@ -697,9 +722,25 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
if (type === "device") {
|
||||
const device: DeviceRegistryEntry | undefined = this.hass.devices?.[item];
|
||||
|
||||
if (device?.primary_config_entry) {
|
||||
this._getDeviceDomain(device.primary_config_entry);
|
||||
}
|
||||
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
|
||||
)
|
||||
? " ◂ "
|
||||
: " ▸ "
|
||||
);
|
||||
|
||||
return {
|
||||
name: device
|
||||
@@ -709,19 +750,17 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
this.hass.states
|
||||
)
|
||||
: item,
|
||||
context: device?.area_id && this.hass.areas?.[device.area_id]?.name,
|
||||
context,
|
||||
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 } = stateObject
|
||||
const { area, device, parentDevice } = stateObject
|
||||
? getEntityContext(
|
||||
stateObject,
|
||||
this.hass.entities,
|
||||
@@ -729,10 +768,17 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
)
|
||||
: { area: undefined, device: undefined };
|
||||
: { area: undefined, device: undefined, parentDevice: undefined };
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const parentDeviceName = parentDevice
|
||||
? computeDeviceName(parentDevice)
|
||||
: undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
const context = [areaName, entityName ? deviceName : undefined]
|
||||
const context = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(
|
||||
computeRTL(
|
||||
@@ -760,7 +806,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
fallbackIconPath: mdiLabel,
|
||||
notFound: !label,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private _setDomainName(domain: string) {
|
||||
this._domainName = domainToName(this.hass.localize, domain);
|
||||
|
||||
@@ -663,6 +663,8 @@ 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
|
||||
) =>
|
||||
|
||||
@@ -31,6 +31,10 @@ export const entityComboBoxKeys: FuseWeightedKey[] = [
|
||||
name: "search_labels.deviceName",
|
||||
weight: 7,
|
||||
},
|
||||
{
|
||||
name: "search_labels.parentDeviceName",
|
||||
weight: 6,
|
||||
},
|
||||
{
|
||||
name: "search_labels.areaName",
|
||||
weight: 6,
|
||||
@@ -129,14 +133,20 @@ export const getEntities = (
|
||||
const stateObj = hass.states[entityId];
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
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 domain = computeDomain(entityId);
|
||||
let domainName = domainNames.get(domain);
|
||||
@@ -146,7 +156,11 @@ export const getEntities = (
|
||||
}
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
@@ -159,6 +173,7 @@ export const getEntities = (
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
domainName: domainName || null,
|
||||
friendlyName: friendlyName || null,
|
||||
|
||||
+33
-17
@@ -1,14 +1,15 @@
|
||||
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";
|
||||
@@ -336,7 +337,7 @@ const processTimelineEntity = (
|
||||
localize: LocalizeFunc,
|
||||
locale: FrontendLocaleData,
|
||||
config: HassConfig,
|
||||
entities: HomeAssistant["entities"],
|
||||
hass: HomeAssistant,
|
||||
entityId: string,
|
||||
states: EntityHistoryState[],
|
||||
current_state: HassEntity | undefined
|
||||
@@ -358,7 +359,7 @@ const processTimelineEntity = (
|
||||
localize,
|
||||
locale,
|
||||
config,
|
||||
entities[entityId],
|
||||
hass.entities[entityId],
|
||||
entityId,
|
||||
{
|
||||
...(state.a || first.a),
|
||||
@@ -374,10 +375,16 @@ const processTimelineEntity = (
|
||||
}
|
||||
|
||||
return {
|
||||
name: computeStateNameFromEntityAttributes(
|
||||
entityId,
|
||||
current_state?.attributes || first.a
|
||||
),
|
||||
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),
|
||||
entity_id: entityId,
|
||||
data,
|
||||
};
|
||||
@@ -387,9 +394,15 @@ const processLineChartEntities = (
|
||||
unit: string,
|
||||
device_class: string | undefined,
|
||||
entities: HistoryStates,
|
||||
hassEntities: HassEntities
|
||||
hass: HomeAssistant
|
||||
): LineChartUnit => {
|
||||
const data: LineChartEntity[] = [];
|
||||
const nameSeparator = computeRTL(
|
||||
hass.language,
|
||||
hass.translationMetadata.translations
|
||||
)
|
||||
? " ◂ "
|
||||
: " ▸ ";
|
||||
|
||||
const entityIds = Object.keys(entities);
|
||||
entityIds.forEach((entityId) => {
|
||||
@@ -436,16 +449,19 @@ const processLineChartEntities = (
|
||||
processedStates.push(processedState);
|
||||
}
|
||||
|
||||
const attributes =
|
||||
entityId in hassEntities
|
||||
? hassEntities[entityId].attributes
|
||||
: "friendly_name" in first.a
|
||||
? first.a
|
||||
: undefined;
|
||||
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 : {}
|
||||
);
|
||||
|
||||
data.push({
|
||||
domain,
|
||||
name: computeStateNameFromEntityAttributes(entityId, attributes || {}),
|
||||
name,
|
||||
entity_id: entityId,
|
||||
states: processedStates,
|
||||
});
|
||||
@@ -610,7 +626,7 @@ export const computeHistory = (
|
||||
localize,
|
||||
hass.locale,
|
||||
hass.config,
|
||||
hass.entities,
|
||||
hass,
|
||||
entityId,
|
||||
stateInfo,
|
||||
currentState
|
||||
@@ -638,7 +654,7 @@ export const computeHistory = (
|
||||
unit,
|
||||
deviceClass,
|
||||
lineChartDevices[key],
|
||||
hass.states
|
||||
hass
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Condition } from "../../../panels/lovelace/common/validate-condition";
|
||||
import type { VisibilityCondition } from "../../../panels/lovelace/common/validate-condition";
|
||||
|
||||
export interface LovelaceBadgeConfig {
|
||||
type: string;
|
||||
[key: string]: any;
|
||||
visibility?: Condition[];
|
||||
visibility?: VisibilityCondition[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Condition } from "../../../panels/lovelace/common/validate-condition";
|
||||
import type { VisibilityCondition } 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?: Condition[];
|
||||
visibility?: VisibilityCondition[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Condition } from "../../../panels/lovelace/common/validate-condition";
|
||||
import type { VisibilityCondition } 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?: Condition[];
|
||||
visibility?: VisibilityCondition[];
|
||||
disabled?: boolean;
|
||||
column_span?: number;
|
||||
row_span?: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { titleCase } from "../../../common/string/title-case";
|
||||
import type { Condition } from "../../../panels/lovelace/common/validate-condition";
|
||||
import type { VisibilityCondition } 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?: Condition[];
|
||||
visibility?: VisibilityCondition[];
|
||||
}
|
||||
|
||||
export interface LovelaceBaseViewConfig {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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);
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
EntityRegistryEntry,
|
||||
} from "./entity/entity_registry";
|
||||
import type { EntitySources } from "./entity/entity_sources";
|
||||
import type { HaDurationData } from "../components/ha-duration-input";
|
||||
|
||||
export type ThresholdMode = "crossed" | "changed" | "is";
|
||||
|
||||
@@ -63,6 +64,7 @@ export type Selector =
|
||||
| NumberSelector
|
||||
| NumericThresholdSelector
|
||||
| ObjectSelector
|
||||
| OffsetSelector
|
||||
| PeriodSelector
|
||||
| AssistPipelineSelector
|
||||
| QRCodeSelector
|
||||
@@ -446,6 +448,20 @@ export interface ObjectSelector {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export type OffsetType = "none" | "before" | "after";
|
||||
|
||||
export interface OffsetSelector {
|
||||
offset: {
|
||||
enable_day?: boolean;
|
||||
enable_millisecond?: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface OffsetSelectorValue {
|
||||
type: OffsetType;
|
||||
duration?: HaDurationData | string | number;
|
||||
}
|
||||
|
||||
export type PeriodKey =
|
||||
| "today"
|
||||
| "yesterday"
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
|
||||
import { formatDurationLong } from "../../common/datetime/format_duration";
|
||||
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 type { OffsetSelectorValue, Selector } from "../selector";
|
||||
|
||||
export const formatSelectorValue = (
|
||||
hass: HomeAssistant,
|
||||
@@ -82,10 +85,7 @@ export const formatSelectorValue = (
|
||||
if (!stateObj) {
|
||||
return entityId;
|
||||
}
|
||||
const name = hass.formatEntityName(stateObj, [
|
||||
{ type: "device" },
|
||||
{ type: "entity" },
|
||||
]);
|
||||
const name = hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
|
||||
return name || entityId;
|
||||
})
|
||||
.join(", ");
|
||||
@@ -125,6 +125,21 @@ export const formatSelectorValue = (
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
if ("offset" in selector) {
|
||||
const { type, duration } = value as OffsetSelectorValue;
|
||||
const durationData = durationValueToData(duration);
|
||||
if (type === "none" || !durationData) {
|
||||
return "";
|
||||
}
|
||||
const formattedDuration = formatDurationLong(hass.locale, durationData);
|
||||
if (!formattedDuration) {
|
||||
return "";
|
||||
}
|
||||
return hass.localize(`ui.components.selectors.offset.summary.${type}`, {
|
||||
duration: formattedDuration,
|
||||
});
|
||||
}
|
||||
|
||||
return ensureArray(value)
|
||||
.map((v) =>
|
||||
v != null && typeof v === "object" ? JSON.stringify(v) : String(v)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { durationDataToSeconds } from "../../common/datetime/duration_to_seconds";
|
||||
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
|
||||
import type { HaDurationData } from "../../components/ha-duration-input";
|
||||
import type { PlatformTrigger } from "../automation";
|
||||
import { TRIGGER_ROW_CONFIG_KEYS } from "../automation";
|
||||
import type { OffsetSelectorValue } from "../selector";
|
||||
import type { TriggerDescription } from "../trigger";
|
||||
|
||||
const TRIGGER_KEYS: (keyof PlatformTrigger)[] = [
|
||||
...TRIGGER_ROW_CONFIG_KEYS,
|
||||
"trigger",
|
||||
"target",
|
||||
"options",
|
||||
];
|
||||
|
||||
const isOffsetSelectorValue = (value: unknown): value is OffsetSelectorValue =>
|
||||
typeof value === "object" && value !== null && "type" in value;
|
||||
|
||||
const absDuration = (duration: HaDurationData): HaDurationData =>
|
||||
Object.fromEntries(
|
||||
Object.entries(duration).map(([field, amount]) => [
|
||||
field,
|
||||
Math.abs(amount ?? 0),
|
||||
])
|
||||
);
|
||||
|
||||
const migrateLegacyOffsetOptions = (
|
||||
options: Record<string, unknown>,
|
||||
fields: TriggerDescription["fields"]
|
||||
): Record<string, unknown> => {
|
||||
let migrated: Record<string, unknown> | undefined;
|
||||
|
||||
for (const [key, field] of Object.entries(fields)) {
|
||||
if (!field.selector || !("offset" in field.selector)) {
|
||||
continue;
|
||||
}
|
||||
const typeKey = `${key}_type`;
|
||||
const value = options[key] as OffsetSelectorValue["duration"] | undefined;
|
||||
const hasLegacyType = typeKey in options;
|
||||
if (
|
||||
!hasLegacyType &&
|
||||
(value === undefined || isOffsetSelectorValue(value))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
migrated ??= { ...options };
|
||||
delete migrated[typeKey];
|
||||
if (isOffsetSelectorValue(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const duration = durationValueToData(value ?? 0);
|
||||
if (!duration || Object.values(duration).some((amount) => isNaN(amount))) {
|
||||
continue;
|
||||
}
|
||||
let seconds = durationDataToSeconds(duration);
|
||||
// The released trigger schema defaulted offset_type to before
|
||||
if (options[typeKey] !== "after") {
|
||||
seconds = -seconds;
|
||||
}
|
||||
migrated[key] =
|
||||
seconds === 0
|
||||
? { type: "none" }
|
||||
: {
|
||||
type: seconds < 0 ? "before" : "after",
|
||||
duration: absDuration(duration),
|
||||
};
|
||||
}
|
||||
|
||||
return migrated ?? options;
|
||||
};
|
||||
|
||||
const moveStrayKeysToOptions = (
|
||||
trigger: PlatformTrigger
|
||||
): PlatformTrigger | undefined => {
|
||||
let migrated: PlatformTrigger | undefined;
|
||||
for (const key in trigger) {
|
||||
if (TRIGGER_KEYS.includes(key as keyof PlatformTrigger)) {
|
||||
continue;
|
||||
}
|
||||
migrated ??= { ...trigger, options: { ...trigger.options } };
|
||||
migrated.options![key] = trigger[key];
|
||||
delete migrated[key];
|
||||
}
|
||||
return migrated;
|
||||
};
|
||||
|
||||
export const migratePlatformTrigger = (
|
||||
trigger: PlatformTrigger,
|
||||
description?: TriggerDescription
|
||||
): PlatformTrigger | undefined => {
|
||||
let migrated = moveStrayKeysToOptions(trigger);
|
||||
|
||||
const options = (migrated ?? trigger).options;
|
||||
if (options && description?.fields) {
|
||||
const migratedOptions = migrateLegacyOffsetOptions(
|
||||
options,
|
||||
description.fields
|
||||
);
|
||||
if (migratedOptions !== options) {
|
||||
migrated = { ...(migrated ?? trigger), options: migratedOptions };
|
||||
}
|
||||
}
|
||||
|
||||
return migrated;
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { domainToName } from "../../data/integration";
|
||||
import type { DataEntryFlowDialogParams } from "./show-dialog-data-entry-flow";
|
||||
import {
|
||||
loadDataEntryFlowDialog,
|
||||
loadFlowStepTranslations,
|
||||
showFlowDialog,
|
||||
} from "./show-dialog-data-entry-flow";
|
||||
|
||||
@@ -32,6 +33,7 @@ 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) => {
|
||||
@@ -45,9 +47,14 @@ 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,6 +154,19 @@ 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,6 +34,8 @@ 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;
|
||||
},
|
||||
@@ -46,16 +48,24 @@ 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) {
|
||||
const description = hass.localize(
|
||||
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.abort.${step.reason}`,
|
||||
step.description_placeholders
|
||||
);
|
||||
// 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
|
||||
);
|
||||
|
||||
return description
|
||||
? html`
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -10,6 +11,7 @@ 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";
|
||||
@@ -91,8 +93,12 @@ 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;
|
||||
@@ -105,6 +111,8 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +123,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -125,6 +134,9 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +165,10 @@ class MoreInfoUpdate extends LitElement {
|
||||
: "generic";
|
||||
|
||||
if (this._isHaOrOsUpdate(updateType)) {
|
||||
if (this._backupConfigLoading) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isBackupConfigValid =
|
||||
!!this._backupConfig &&
|
||||
!!this._backupConfig.automatic_backups_configured &&
|
||||
@@ -332,25 +348,34 @@ class MoreInfoUpdate extends LitElement {
|
||||
</div>
|
||||
<div class="footer">
|
||||
${
|
||||
createBackupTexts
|
||||
createBackupTexts || this._backupConfigLoading
|
||||
? html`
|
||||
<ha-row-item>
|
||||
<span slot="headline">${createBackupTexts.title}</span>
|
||||
<ha-row-item
|
||||
.headline=${createBackupTexts ? createBackupTexts.title : undefined}
|
||||
.supportingText=${createBackupTexts ? createBackupTexts.description : undefined}
|
||||
>
|
||||
${
|
||||
createBackupTexts.description
|
||||
? html`
|
||||
<span slot="supporting-text">
|
||||
${createBackupTexts.description}
|
||||
</span>
|
||||
`
|
||||
!createBackupTexts
|
||||
? html`<ha-fade-in slot="headline" .delay=${500}
|
||||
><wa-skeleton effect="sheen"></wa-skeleton
|
||||
></ha-fade-in>`
|
||||
: nothing
|
||||
}
|
||||
<ha-switch
|
||||
slot="end"
|
||||
.checked=${this._createBackup}
|
||||
@change=${this._createBackupChanged}
|
||||
.disabled=${updateIsInstalling(this.stateObj)}
|
||||
></ha-switch>
|
||||
${
|
||||
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-row-item>
|
||||
`
|
||||
: nothing
|
||||
@@ -416,19 +441,34 @@ 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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
if (this._isHaOrOsUpdate(type)) {
|
||||
this._fetchBackupConfig();
|
||||
}
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,6 +627,11 @@ class MoreInfoUpdate extends LitElement {
|
||||
box-sizing: border-box;
|
||||
padding-bottom: var(--ha-space-4);
|
||||
}
|
||||
.skeleton-end {
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -591,11 +591,17 @@ 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, deviceName, entityName].filter(
|
||||
(v): v is string => Boolean(v)
|
||||
);
|
||||
const breadcrumb = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
deviceName,
|
||||
entityName,
|
||||
].filter((v): v is string => Boolean(v));
|
||||
const addToMenuItem = this.hass.localize(
|
||||
"ui.dialogs.more_info_control.add_to.item"
|
||||
);
|
||||
|
||||
@@ -82,7 +82,7 @@ class HaMoreInfoRelated extends LitElement {
|
||||
? (domain as ItemType)
|
||||
: "entity";
|
||||
|
||||
const { floor, area, device } = getEntityContext(
|
||||
const { floor, area, device, parentDevice } = getEntityContext(
|
||||
this._stateObj,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
@@ -91,6 +91,13 @@ 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;
|
||||
@@ -124,6 +131,14 @@ 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",
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { PropertyValues, ReactiveElement } from "lit";
|
||||
import { state } from "lit/decorators";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { setupConditionListeners } from "../common/condition/listeners";
|
||||
import type { ConditionEvaluation } from "../common/controllers/condition-evaluator-controller";
|
||||
import { ConditionEvaluatorController } from "../common/controllers/condition-evaluator-controller";
|
||||
import { maxColumnsContext } from "../panels/lovelace/common/context";
|
||||
import { evaluateConditionsLocally } from "../common/condition/evaluate-locally";
|
||||
import type {
|
||||
Condition,
|
||||
ConditionContext,
|
||||
VisibilityCondition,
|
||||
} from "../panels/lovelace/common/validate-condition";
|
||||
import { addEntityToCondition } from "../panels/lovelace/common/validate-condition";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
type Constructor<T> = abstract new (...args: any[]) => T;
|
||||
|
||||
@@ -15,27 +18,19 @@ type Constructor<T> = abstract new (...args: any[]) => T;
|
||||
* Base config type that can be used with conditional listeners
|
||||
*/
|
||||
export interface ConditionalConfig {
|
||||
visibility?: Condition[];
|
||||
visibility?: VisibilityCondition[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mixin to handle conditional listeners for visibility control
|
||||
* Mixin for dashboard visibility.
|
||||
*
|
||||
* Provides lifecycle management for listeners that control conditional
|
||||
* visibility of components.
|
||||
* Stateful conditions go to core via `subscribe_condition`; screen/user/time
|
||||
* stay local. Call `_conditionsVisible()` from `_updateVisibility` /
|
||||
* `_updateElement`.
|
||||
*
|
||||
* 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
|
||||
* Override `setupConditionalListeners()` to pass a custom list (e.g. a
|
||||
* conditional card's `conditions`).
|
||||
*/
|
||||
export const ConditionalListenerMixin = <
|
||||
TConfig extends ConditionalConfig = ConditionalConfig,
|
||||
@@ -43,8 +38,6 @@ export const ConditionalListenerMixin = <
|
||||
superClass: Constructor<ReactiveElement>
|
||||
) => {
|
||||
abstract class ConditionalListenerClass extends superClass {
|
||||
private __listeners: (() => void)[] = [];
|
||||
|
||||
protected _config?: TConfig;
|
||||
|
||||
public config?: TConfig;
|
||||
@@ -57,6 +50,40 @@ 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;
|
||||
@@ -66,11 +93,6 @@ export const ConditionalListenerMixin = <
|
||||
this.setupConditionalListeners();
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.clearConditionalListeners();
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues) {
|
||||
super.willUpdate(changedProperties);
|
||||
if (changedProperties.has("_maxColumns")) {
|
||||
@@ -83,67 +105,80 @@ export const ConditionalListenerMixin = <
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has("_maxColumns")) {
|
||||
this._updateVisibility?.();
|
||||
// After willUpdate so consumers can set `_conditionContext.entity_id`.
|
||||
if (
|
||||
changedProperties.has("hass") ||
|
||||
changedProperties.has("config") ||
|
||||
changedProperties.has("_config") ||
|
||||
changedProperties.has("_maxColumns")
|
||||
) {
|
||||
this.setupConditionalListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
protected clearConditionalListeners(): void {
|
||||
this.__listeners.forEach((unsub) => unsub());
|
||||
this.__listeners = [];
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Pass conditions to the evaluator.
|
||||
* Override to supply a custom list, then call `super.setupConditionalListeners(...)`.
|
||||
*/
|
||||
protected addConditionalListener(unsubscribe: () => void): void {
|
||||
this.__listeners.push(unsubscribe);
|
||||
}
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
this.__conditions = finalConditions;
|
||||
|
||||
if (!finalConditions || !this.hass) {
|
||||
return;
|
||||
// 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;
|
||||
}
|
||||
|
||||
setupConditionListeners(
|
||||
finalConditions,
|
||||
this.__conditionEvaluator.observe(
|
||||
this.__observed,
|
||||
this.hass,
|
||||
(unsub) => this.addConditionalListener(unsub),
|
||||
(conditionsMet) => {
|
||||
if (this._updateVisibility) {
|
||||
this._updateVisibility(conditionsMet);
|
||||
} else if (this._updateElement && config) {
|
||||
this._updateElement(config);
|
||||
}
|
||||
},
|
||||
() => this._conditionContext
|
||||
);
|
||||
}
|
||||
|
||||
@@ -267,6 +267,7 @@ class OnboardingLocation extends LitElement {
|
||||
? location[1]
|
||||
: Number(place.lon),
|
||||
location_editable: place.place_id === highlightedMarker,
|
||||
clickable: true,
|
||||
}))
|
||||
: [];
|
||||
}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiAppleKeyboardCommand,
|
||||
mdiClose,
|
||||
mdiContentPaste,
|
||||
mdiHelpCircleOutline,
|
||||
mdiPlus,
|
||||
} from "@mdi/js";
|
||||
import { mdiClose, mdiHelpCircleOutline } 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";
|
||||
@@ -19,9 +13,11 @@ 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 {
|
||||
@@ -122,8 +118,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";
|
||||
@@ -724,6 +720,9 @@ 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
|
||||
@@ -733,56 +732,12 @@ class DialogAddAutomationElement
|
||||
"ha-scrollbar": true,
|
||||
})}
|
||||
>
|
||||
${
|
||||
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
|
||||
}
|
||||
<ha-automation-add-element-paste
|
||||
.automationElementType=${automationElementType}
|
||||
.clipboardItem=${this._params!.clipboardItem}
|
||||
@paste-element=${this._paste}
|
||||
divider
|
||||
></ha-automation-add-element-paste>
|
||||
${collections.map(
|
||||
(collection) => html`
|
||||
${
|
||||
@@ -948,6 +903,12 @@ class DialogAddAutomationElement
|
||||
const [targetType, targetId] = this._extractTypeAndIdFromTarget(
|
||||
this._selectedTarget
|
||||
);
|
||||
const separator = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? " ◂ "
|
||||
: " ▸ ";
|
||||
|
||||
if (targetId) {
|
||||
if (targetType === "area") {
|
||||
@@ -960,11 +921,21 @@ class DialogAddAutomationElement
|
||||
);
|
||||
}
|
||||
} else if (targetType === "device") {
|
||||
const areaId = this.hass.devices[targetId]?.area_id;
|
||||
if (areaId) {
|
||||
subtitle = computeAreaName(this.hass.areas[areaId]) || areaId;
|
||||
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);
|
||||
} else {
|
||||
const device = this.hass.devices[targetId];
|
||||
subtitle = this.hass.localize(
|
||||
`ui.panel.config.automation.editor.${device?.entry_type === "service" ? "services" : "unassigned_devices"}`
|
||||
);
|
||||
@@ -980,25 +951,28 @@ class DialogAddAutomationElement
|
||||
);
|
||||
} else {
|
||||
const stateObj = this.hass.states[targetId];
|
||||
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, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(
|
||||
computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? " ◂ "
|
||||
: " ▸ "
|
||||
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
|
||||
);
|
||||
|
||||
subtitle = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(separator);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2447,17 +2421,18 @@ class DialogAddAutomationElement
|
||||
border-radius: var(--ha-border-radius-xl);
|
||||
border: 1px solid var(--ha-color-border-neutral-quiet);
|
||||
margin: var(--ha-space-3);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@media (max-width: 870px), (max-height: 500px) {
|
||||
ha-automation-add-from-target,
|
||||
.groups {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
ha-automation-add-from-target.hidden {
|
||||
display: none;
|
||||
}
|
||||
@@ -2526,29 +2501,10 @@ 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;
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+39
-22
@@ -65,6 +65,8 @@ 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;
|
||||
@@ -105,6 +107,11 @@ 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
|
||||
@@ -193,6 +200,12 @@ 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,
|
||||
@@ -344,28 +357,32 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
);
|
||||
});
|
||||
|
||||
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
|
||||
>`
|
||||
}`
|
||||
}`;
|
||||
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
|
||||
>`
|
||||
}`
|
||||
}`;
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
mdiArrowDown,
|
||||
mdiArrowUp,
|
||||
mdiCommentEditOutline,
|
||||
mdiCommentTextOutline,
|
||||
mdiContentCopy,
|
||||
mdiContentCut,
|
||||
mdiContentPaste,
|
||||
@@ -19,7 +18,6 @@ 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";
|
||||
@@ -32,10 +30,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";
|
||||
@@ -64,7 +62,6 @@ 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,
|
||||
@@ -73,12 +70,7 @@ 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";
|
||||
@@ -181,26 +173,6 @@ 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"
|
||||
@@ -226,56 +198,15 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
></ha-condition-icon>
|
||||
</div>`
|
||||
}
|
||||
<h3 slot="header">
|
||||
${capitalizeFirstLetter(
|
||||
<ha-automation-condition-summary
|
||||
slot="header"
|
||||
.label=${capitalizeFirstLetter(
|
||||
describeCondition(this.condition, this.hass, this._entityReg)
|
||||
)}
|
||||
${
|
||||
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>
|
||||
.condition=${this.condition}
|
||||
.description=${this.conditionDescriptions[this.condition.condition]}
|
||||
.isNew=${this._isNew}
|
||||
></ha-automation-condition-summary>
|
||||
<ha-automation-row-event-chip
|
||||
.show=${this._testing}
|
||||
.variant=${this._testingResult ? "success" : "warning"}
|
||||
@@ -621,45 +552,6 @@ 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);
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@ import { createDurationData } from "../../../../../common/datetime/create_durati
|
||||
import { durationDataToSeconds } from "../../../../../common/datetime/duration_to_seconds";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../../common/dom/stop_propagation";
|
||||
import { getSelectorFallbackValue } from "../../../../../components/ha-form/get-selector-fallback-value";
|
||||
import { afterNextRender } from "../../../../../common/util/render-status";
|
||||
import "../../../../../components/ha-checkbox";
|
||||
import { getSelectorFallbackValue } from "../../../../../components/ha-form/get-selector-fallback-value";
|
||||
import "../../../../../components/ha-selector/ha-selector";
|
||||
import "../../../../../components/ha-settings-row";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
@@ -163,7 +164,8 @@ export class HaPlatformCondition extends LitElement {
|
||||
}
|
||||
|
||||
if (oldValue?.target !== this.condition?.target) {
|
||||
this._updateResolvedTargetEntityCount(this.condition?.target);
|
||||
this._updateTargetEntityCount();
|
||||
this._setDefaultBehavior();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,41 +478,51 @@ export class HaPlatformCondition extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _updateResolvedTargetEntityCount(
|
||||
target: PlatformCondition["target"]
|
||||
) {
|
||||
private _updateTargetEntityCount() {
|
||||
const target = this.condition?.target;
|
||||
this._resolvedTargetEntityCount = getTargetEntityCount(target);
|
||||
}
|
||||
|
||||
const behaviorFieldEntry = Object.entries(
|
||||
this.description?.fields ?? {}
|
||||
).find(
|
||||
([, field]) => field.selector && "automation_behavior" in field.selector
|
||||
);
|
||||
|
||||
if (!behaviorFieldEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [behaviorFieldName, behaviorField] = behaviorFieldEntry;
|
||||
|
||||
if (
|
||||
target &&
|
||||
this._resolvedTargetEntityCount > 1 &&
|
||||
this.condition.options?.[behaviorFieldName] === undefined
|
||||
) {
|
||||
const behaviorDefault = behaviorField.default;
|
||||
if (behaviorDefault !== undefined) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.condition,
|
||||
options: {
|
||||
...this.condition.options,
|
||||
[behaviorFieldName]: behaviorDefault,
|
||||
},
|
||||
},
|
||||
});
|
||||
private _setDefaultBehavior() {
|
||||
// set default behavior after next render to prevent race conditions with the initial render
|
||||
afterNextRender(() => {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const behaviorFieldEntry = Object.entries(
|
||||
this.description?.fields ?? {}
|
||||
).find(
|
||||
([, field]) => field.selector && "automation_behavior" in field.selector
|
||||
);
|
||||
|
||||
if (
|
||||
!behaviorFieldEntry ||
|
||||
this._resolvedTargetEntityCount === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [behaviorFieldName, behaviorField] = behaviorFieldEntry;
|
||||
if (
|
||||
this.condition?.target &&
|
||||
this._resolvedTargetEntityCount > 1 &&
|
||||
this.condition.options?.[behaviorFieldName] === undefined
|
||||
) {
|
||||
const behaviorDefault = behaviorField.default;
|
||||
if (behaviorDefault !== undefined) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.condition,
|
||||
options: {
|
||||
...this.condition.options,
|
||||
[behaviorFieldName]: behaviorDefault,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Shows a small info icon beside the `for` duration field's label, with a
|
||||
|
||||
@@ -313,3 +313,20 @@ export const overflowStyles = css`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const shortcutStyles = css`
|
||||
.shortcut {
|
||||
direction: ltr;
|
||||
--mdc-icon-size: var(--ha-space-3);
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-right: var(--ha-space-4);
|
||||
}
|
||||
.shortcut span {
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-family: var(--ha-font-family-code);
|
||||
color: var(--ha-color-text-secondary);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -4,12 +4,12 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { getSelectorFallbackValue } from "../../../../../components/ha-form/get-selector-fallback-value";
|
||||
import { afterNextRender } from "../../../../../common/util/render-status";
|
||||
import "../../../../../components/ha-checkbox";
|
||||
import { getSelectorFallbackValue } from "../../../../../components/ha-form/get-selector-fallback-value";
|
||||
import "../../../../../components/ha-selector/ha-selector";
|
||||
import "../../../../../components/ha-settings-row";
|
||||
import type { PlatformTrigger } from "../../../../../data/automation";
|
||||
import { TRIGGER_ROW_CONFIG_KEYS } from "../../../../../data/automation";
|
||||
import type { IntegrationManifest } from "../../../../../data/integration";
|
||||
import { fetchIntegrationManifest } from "../../../../../data/integration";
|
||||
import type { TargetSelector } from "../../../../../data/selector";
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getTriggerObjectId,
|
||||
type TriggerDescription,
|
||||
} from "../../../../../data/trigger";
|
||||
import { migratePlatformTrigger } from "../../../../../data/trigger/migrate_platform_trigger";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { documentationUrl } from "../../../../../util/documentation-url";
|
||||
|
||||
@@ -27,13 +28,6 @@ const showOptionalToggle = (field: TriggerDescription["fields"][string]) =>
|
||||
!field.required &&
|
||||
!("boolean" in field.selector && field.default);
|
||||
|
||||
const DEFAULT_KEYS: (keyof PlatformTrigger)[] = [
|
||||
...TRIGGER_ROW_CONFIG_KEYS,
|
||||
"trigger",
|
||||
"target",
|
||||
"options",
|
||||
];
|
||||
|
||||
@customElement("ha-automation-trigger-platform")
|
||||
export class HaPlatformTrigger extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -60,34 +54,21 @@ export class HaPlatformTrigger extends LitElement {
|
||||
this.hass.loadBackendTranslation("triggers");
|
||||
this.hass.loadBackendTranslation("selector");
|
||||
}
|
||||
if (
|
||||
changedProperties.has("trigger") ||
|
||||
changedProperties.has("description")
|
||||
) {
|
||||
const migrated = migratePlatformTrigger(this.trigger, this.description);
|
||||
if (migrated) {
|
||||
fireEvent(this, "value-changed", { value: migrated });
|
||||
this.trigger = migrated;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changedProperties.has("trigger")) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newValue: PlatformTrigger | undefined;
|
||||
|
||||
for (const key in this.trigger) {
|
||||
// Migrate old options to `options`
|
||||
if (DEFAULT_KEYS.includes(key as keyof PlatformTrigger)) {
|
||||
continue;
|
||||
}
|
||||
if (newValue === undefined) {
|
||||
newValue = {
|
||||
...this.trigger,
|
||||
options: { [key]: this.trigger[key] },
|
||||
};
|
||||
} else {
|
||||
newValue.options![key] = this.trigger[key];
|
||||
}
|
||||
delete newValue[key];
|
||||
}
|
||||
if (newValue !== undefined) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: newValue,
|
||||
});
|
||||
this.trigger = newValue;
|
||||
}
|
||||
|
||||
const oldValue = changedProperties.get("trigger") as
|
||||
undefined | this["trigger"];
|
||||
|
||||
@@ -155,7 +136,8 @@ export class HaPlatformTrigger extends LitElement {
|
||||
}
|
||||
|
||||
if (oldValue?.target !== this.trigger?.target) {
|
||||
this._updateResolvedTargetEntityCount(this.trigger?.target);
|
||||
this._updateTargetEntityCount();
|
||||
this._setDefaultBehavior();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,39 +448,51 @@ export class HaPlatformTrigger extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _updateResolvedTargetEntityCount(target: PlatformTrigger["target"]) {
|
||||
private _updateTargetEntityCount() {
|
||||
const target = this.trigger?.target;
|
||||
this._resolvedTargetEntityCount = getTargetEntityCount(target);
|
||||
}
|
||||
|
||||
const behaviorFieldEntry = Object.entries(
|
||||
this.description?.fields ?? {}
|
||||
).find(
|
||||
([, field]) => field.selector && "automation_behavior" in field.selector
|
||||
);
|
||||
|
||||
if (!behaviorFieldEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [behaviorFieldName, behaviorField] = behaviorFieldEntry;
|
||||
|
||||
if (
|
||||
target &&
|
||||
this._resolvedTargetEntityCount > 1 &&
|
||||
this.trigger.options?.[behaviorFieldName] === undefined
|
||||
) {
|
||||
const behaviorDefault = behaviorField.default;
|
||||
if (behaviorDefault !== undefined) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.trigger,
|
||||
options: {
|
||||
...this.trigger.options,
|
||||
[behaviorFieldName]: behaviorDefault,
|
||||
},
|
||||
},
|
||||
});
|
||||
private _setDefaultBehavior() {
|
||||
// set default behavior after next render to prevent race conditions with the initial render
|
||||
afterNextRender(() => {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const behaviorFieldEntry = Object.entries(
|
||||
this.description?.fields ?? {}
|
||||
).find(
|
||||
([, field]) => field.selector && "automation_behavior" in field.selector
|
||||
);
|
||||
|
||||
if (
|
||||
!behaviorFieldEntry ||
|
||||
this._resolvedTargetEntityCount === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [behaviorFieldName, behaviorField] = behaviorFieldEntry;
|
||||
if (
|
||||
this.trigger?.target &&
|
||||
this._resolvedTargetEntityCount > 1 &&
|
||||
this.trigger.options?.[behaviorFieldName] === undefined
|
||||
) {
|
||||
const behaviorDefault = behaviorField.default;
|
||||
if (behaviorDefault !== undefined) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.trigger,
|
||||
options: {
|
||||
...this.trigger.options,
|
||||
[behaviorFieldName]: behaviorDefault,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
|
||||
@@ -148,7 +148,7 @@ class DialogRestoreBackup
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
header-title=${dialogTitle}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
prevent-scrim-close
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<div class="content">
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
mdiStarFourPoints,
|
||||
mdiTextBoxOutline,
|
||||
mdiTools,
|
||||
mdiTransitConnectionVariant,
|
||||
mdiUpdate,
|
||||
mdiViewDashboard,
|
||||
mdiZigbee,
|
||||
@@ -188,6 +189,14 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
translationKey: "serial",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/modbus",
|
||||
iconPath: mdiTransitConnectionVariant,
|
||||
iconColor: "#00897B",
|
||||
component: "modbus",
|
||||
translationKey: "modbus",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/infrared",
|
||||
iconPath: mdiRemote,
|
||||
|
||||
@@ -31,6 +31,7 @@ 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: "id", weight: 2 },
|
||||
@@ -64,14 +65,20 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
|
||||
if (stateObj) {
|
||||
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 [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 friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
|
||||
@@ -89,6 +96,7 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
},
|
||||
|
||||
@@ -160,11 +160,17 @@ export class DialogVacuumSegmentMapping
|
||||
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, deviceName, entityName].filter(
|
||||
(v): v is string => Boolean(v)
|
||||
);
|
||||
const breadcrumb = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
deviceName,
|
||||
entityName,
|
||||
].filter((v): v is string => Boolean(v));
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,903 +0,0 @@
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiAlertCircle,
|
||||
mdiCancel,
|
||||
mdiEyeOff,
|
||||
mdiPencilOff,
|
||||
mdiRestoreAlert,
|
||||
} from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { guard } from "lit/directives/guard";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoize from "memoize-one";
|
||||
import {
|
||||
fireEvent,
|
||||
type HASSDomCurrentTargetEvent,
|
||||
type HASSDomEvent,
|
||||
} from "../../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../../common/entity/compute_area_name";
|
||||
import {
|
||||
computeDeviceName,
|
||||
getDuplicatedDeviceNames,
|
||||
} from "../../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { computeEntityEntryName } from "../../../common/entity/compute_entity_name";
|
||||
import { computeStateName } from "../../../common/entity/compute_state_name";
|
||||
import { slugify } from "../../../common/string/slugify";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { deepEqual } from "../../../common/util/deep-equal";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
RowClickedEvent,
|
||||
SortingChangedEvent,
|
||||
} from "../../../components/data-table/ha-data-table";
|
||||
import "../../../components/data-table/ha-data-table-labels";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-entity-id-icon";
|
||||
import "../../../components/ha-filter-devices";
|
||||
import "../../../components/ha-filter-domains";
|
||||
import "../../../components/ha-filter-floor-areas";
|
||||
import "../../../components/ha-filter-integrations";
|
||||
import "../../../components/ha-filter-labels";
|
||||
import "../../../components/ha-filter-states";
|
||||
import "../../../components/ha-filter-voice-assistants";
|
||||
import "../../../components/ha-icon";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-tooltip";
|
||||
import type { CloudStatus } from "../../../data/cloud";
|
||||
import type { ConfigEntry, SubEntry } from "../../../data/config_entries";
|
||||
import { getSubEntries } from "../../../data/config_entries";
|
||||
import {
|
||||
areasContext,
|
||||
configEntriesContext,
|
||||
devicesContext,
|
||||
fullEntitiesContext,
|
||||
internationalizationContext,
|
||||
labelsContext,
|
||||
statesContext,
|
||||
} from "../../../data/context";
|
||||
import type {
|
||||
DataTableFilter,
|
||||
DataTableFiltersItems,
|
||||
DataTableFiltersValues,
|
||||
} from "../../../data/data_table_filters";
|
||||
import { UNAVAILABLE } from "../../../data/entity/entity";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import type { EntitySources } from "../../../data/entity/entity_sources";
|
||||
import { fetchEntitySourcesWithCache } from "../../../data/entity/entity_sources";
|
||||
import type { ExposeEntitySettings } from "../../../data/expose";
|
||||
import { listExposedEntities, voiceAssistants } from "../../../data/expose";
|
||||
import { domainToName } from "../../../data/integration";
|
||||
import type { LabelRegistryEntry } from "../../../data/label/label_registry";
|
||||
import "../../../layouts/hass-loading-screen";
|
||||
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
|
||||
import "../../../layouts/hass-tabs-subpage-data-table";
|
||||
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
HomeAssistantInternationalization,
|
||||
Route,
|
||||
} from "../../../types";
|
||||
import {
|
||||
getAreaTableColumn,
|
||||
getCreatedAtTableColumn,
|
||||
getDomainTableColumn,
|
||||
getEntityIdTableColumn,
|
||||
getLabelsTableColumn,
|
||||
getModifiedAtTableColumn,
|
||||
} from "../common/data-table-columns";
|
||||
import {
|
||||
getAssistantsSortableKey,
|
||||
getAssistantsTableColumn,
|
||||
} from "../voice-assistants/expose/assistants-table-column";
|
||||
import { getAvailableAssistants } from "../voice-assistants/expose/available-assistants";
|
||||
|
||||
export interface StateEntity extends Omit<
|
||||
EntityRegistryEntry,
|
||||
"id" | "unique_id"
|
||||
> {
|
||||
readonly?: boolean;
|
||||
selectable?: boolean;
|
||||
id?: string;
|
||||
unique_id?: string;
|
||||
}
|
||||
|
||||
export interface EntityRow extends StateEntity {
|
||||
unavailable: boolean;
|
||||
restored: boolean;
|
||||
status: string | undefined;
|
||||
area?: string;
|
||||
device?: string;
|
||||
device_full?: string;
|
||||
localized_platform: string;
|
||||
domain: string;
|
||||
label_entries: LabelRegistryEntry[];
|
||||
assistants: string[];
|
||||
assistants_sortable_key: string | undefined;
|
||||
enabled: string;
|
||||
visible: string;
|
||||
available: string;
|
||||
}
|
||||
|
||||
export interface EntityTableFilterContext {
|
||||
filteredDomains: ReadonlySet<string>;
|
||||
filteredConfigEntry: ConfigEntry | undefined;
|
||||
}
|
||||
|
||||
@customElement("ha-entity-table")
|
||||
export class HaEntityTable extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
@property({ attribute: false }) public tabs: PageNavigation[] = [];
|
||||
@property({ attribute: "back-path" }) public backPath?: string;
|
||||
@property({ attribute: false }) public cloudStatus?: CloudStatus;
|
||||
@property() public filter = "";
|
||||
@property({ attribute: false }) public filterValues: DataTableFiltersValues =
|
||||
{};
|
||||
@property({ type: Boolean }) public selectable = false;
|
||||
@property({ type: Boolean }) public clickable = false;
|
||||
@property({ type: Number }) public selected = 0;
|
||||
@property({ attribute: "has-fab", type: Boolean }) public hasFab = false;
|
||||
@property({ attribute: false }) public initialSorting?: SortingChangedEvent;
|
||||
@property({ attribute: false }) public initialGroupColumn?: string;
|
||||
@property({ attribute: false }) public initialCollapsedGroups?: string[];
|
||||
@property({ attribute: false }) public columnOrder?: string[];
|
||||
@property({ attribute: false }) public hiddenColumns?: string[];
|
||||
|
||||
@state()
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
private _entities?: EntityRegistryEntry[];
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _entityStates!: HomeAssistant["states"];
|
||||
@state()
|
||||
@consume({ context: devicesContext, subscribe: true })
|
||||
private _devices!: HomeAssistant["devices"];
|
||||
@state()
|
||||
@consume({ context: areasContext, subscribe: true })
|
||||
private _areas!: HomeAssistant["areas"];
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: HomeAssistantInternationalization;
|
||||
@state()
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
private _labels?: LabelRegistryEntry[];
|
||||
@state()
|
||||
@consume({ context: configEntriesContext, subscribe: true })
|
||||
private _entries?: ConfigEntry[];
|
||||
|
||||
@state() private _subEntries?: SubEntry[];
|
||||
@state() private _entitySources?: EntitySources;
|
||||
@state() private _exposedEntities?: Record<string, ExposeEntitySettings>;
|
||||
@state() private _filteredItems: DataTableFiltersItems = {};
|
||||
@state() private _expandedFilter?: string;
|
||||
private _itemFilterValues: DataTableFiltersValues = {};
|
||||
private _subEntryConfigEntry?: string;
|
||||
private _filterContext?: EntityTableFilterContext;
|
||||
|
||||
@query("hass-tabs-subpage-data-table")
|
||||
private _dataTable?: HaTabsSubpageDataTable;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener(
|
||||
"exposed-entities-changed",
|
||||
this._fetchExposedEntities
|
||||
);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener(
|
||||
"exposed-entities-changed",
|
||||
this._fetchExposedEntities
|
||||
);
|
||||
}
|
||||
|
||||
public clearSelection() {
|
||||
this._dataTable?.clearSelection();
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
this._fetchExposedEntities();
|
||||
fetchEntitySourcesWithCache(this.hass).then((sources) => {
|
||||
this._entitySources = sources;
|
||||
});
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
if (changedProps.has("filterValues")) {
|
||||
this._filteredItems = Object.fromEntries(
|
||||
Object.entries(this._filteredItems).filter(
|
||||
([key]) =>
|
||||
this.filterValues[key] !== undefined &&
|
||||
deepEqual(this.filterValues[key], this._itemFilterValues[key])
|
||||
)
|
||||
);
|
||||
const configEntry =
|
||||
Array.isArray(this.filterValues.config_entry) &&
|
||||
this.filterValues.config_entry.length === 1
|
||||
? this.filterValues.config_entry[0]
|
||||
: undefined;
|
||||
const subEntryConfigEntry =
|
||||
Array.isArray(this.filterValues.sub_entry) &&
|
||||
this.filterValues.sub_entry.length
|
||||
? configEntry
|
||||
: undefined;
|
||||
if (subEntryConfigEntry !== this._subEntryConfigEntry) {
|
||||
this._subEntryConfigEntry = subEntryConfigEntry;
|
||||
this._subEntries = undefined;
|
||||
if (subEntryConfigEntry) {
|
||||
this._loadSubEntries(subEntryConfigEntry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected updated() {
|
||||
if (!this._entities) {
|
||||
return;
|
||||
}
|
||||
const { filteredDomains, filteredConfigEntry } = this._getRows();
|
||||
if (
|
||||
this._filterContext &&
|
||||
this._filterContext.filteredConfigEntry === filteredConfigEntry &&
|
||||
this._filterContext.filteredDomains.size === filteredDomains.size &&
|
||||
[...filteredDomains].every((domain) =>
|
||||
this._filterContext!.filteredDomains.has(domain)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._filterContext = { filteredDomains, filteredConfigEntry };
|
||||
fireEvent(this, "entity-table-filter-context-changed", this._filterContext);
|
||||
}
|
||||
|
||||
private _states = memoize((localize: LocalizeFunc) => [
|
||||
{
|
||||
value: "available",
|
||||
label: localize("ui.panel.config.entities.picker.status.available"),
|
||||
},
|
||||
{
|
||||
value: "unavailable",
|
||||
label: localize("ui.panel.config.entities.picker.status.unavailable"),
|
||||
},
|
||||
{
|
||||
value: "enabled",
|
||||
label: localize("ui.panel.config.entities.picker.status.enabled"),
|
||||
},
|
||||
{
|
||||
value: "disabled",
|
||||
label: localize("ui.panel.config.entities.picker.status.disabled"),
|
||||
},
|
||||
{
|
||||
value: "visible",
|
||||
label: localize("ui.panel.config.entities.picker.status.visible"),
|
||||
},
|
||||
{
|
||||
value: "hidden",
|
||||
label: localize("ui.panel.config.entities.picker.status.hidden"),
|
||||
},
|
||||
{
|
||||
value: "readonly",
|
||||
label: localize("ui.panel.config.entities.picker.status.unmanageable"),
|
||||
},
|
||||
{
|
||||
value: "restored",
|
||||
label: localize("ui.panel.config.entities.picker.status.not_provided"),
|
||||
},
|
||||
]);
|
||||
|
||||
private _columns = memoize(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
hass: HomeAssistant,
|
||||
availableAssistants: string[],
|
||||
entitiesToCheck: EntityRow[]
|
||||
): DataTableColumnContainer<EntityRow> => ({
|
||||
icon: {
|
||||
title: "",
|
||||
label: localize("ui.panel.config.entities.picker.headers.state_icon"),
|
||||
type: "icon",
|
||||
showNarrow: true,
|
||||
moveable: false,
|
||||
template: (entry) =>
|
||||
entry.icon
|
||||
? html`<ha-icon .icon=${entry.icon}></ha-icon>`
|
||||
: html`<ha-entity-id-icon
|
||||
state-title
|
||||
slot="item-icon"
|
||||
.entityId=${entry.entity_id}
|
||||
></ha-entity-id-icon>`,
|
||||
},
|
||||
name: {
|
||||
main: true,
|
||||
title: localize("ui.panel.config.entities.picker.headers.entity"),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
direction: "asc",
|
||||
extraTemplate: (entry) =>
|
||||
entry.label_entries.length
|
||||
? html`<ha-data-table-labels
|
||||
.labels=${entry.label_entries}
|
||||
></ha-data-table-labels>`
|
||||
: nothing,
|
||||
},
|
||||
device: {
|
||||
title: localize("ui.panel.config.entities.picker.headers.device"),
|
||||
sortable: true,
|
||||
template: (entry) => entry.device || "\u2014",
|
||||
},
|
||||
device_full: {
|
||||
title: localize("ui.panel.config.entities.picker.headers.device"),
|
||||
filterable: true,
|
||||
groupable: true,
|
||||
hidden: true,
|
||||
},
|
||||
area: getAreaTableColumn(localize),
|
||||
entity_id: getEntityIdTableColumn(localize, true),
|
||||
localized_platform: {
|
||||
title: localize("ui.panel.config.entities.picker.headers.integration"),
|
||||
sortable: true,
|
||||
groupable: true,
|
||||
filterable: true,
|
||||
},
|
||||
domain: getDomainTableColumn(localize),
|
||||
disabled_by: {
|
||||
title: localize("ui.panel.config.entities.picker.headers.disabled_by"),
|
||||
hidden: true,
|
||||
filterable: true,
|
||||
template: (entry) =>
|
||||
entry.disabled_by === null
|
||||
? ""
|
||||
: localize(`config_entry.disabled_by.${entry.disabled_by}`),
|
||||
},
|
||||
status: {
|
||||
title: localize("ui.panel.config.entities.picker.headers.status"),
|
||||
type: "icon",
|
||||
showNarrow: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
minWidth: "80px",
|
||||
maxWidth: "80px",
|
||||
template: (entry) =>
|
||||
entry.unavailable ||
|
||||
entry.disabled_by ||
|
||||
entry.hidden_by ||
|
||||
entry.readonly
|
||||
? html`<div
|
||||
tabindex="0"
|
||||
style="display:inline-block; position: relative;"
|
||||
>
|
||||
<ha-svg-icon
|
||||
.id="status-icon-${slugify(entry.entity_id)}"
|
||||
style=${styleMap({ color: entry.unavailable ? "var(--error-color)" : "" })}
|
||||
.path=${entry.restored ? mdiRestoreAlert : entry.unavailable ? mdiAlertCircle : entry.disabled_by ? mdiCancel : entry.hidden_by ? mdiEyeOff : mdiPencilOff}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip
|
||||
.for="status-icon-${slugify(entry.entity_id)}"
|
||||
placement="left"
|
||||
>
|
||||
${
|
||||
entry.restored
|
||||
? localize(
|
||||
"ui.panel.config.entities.picker.status.not_provided"
|
||||
)
|
||||
: entry.unavailable
|
||||
? localize(
|
||||
"ui.panel.config.entities.picker.status.unavailable"
|
||||
)
|
||||
: entry.disabled_by
|
||||
? localize(
|
||||
"ui.panel.config.entities.picker.status.disabled"
|
||||
)
|
||||
: entry.hidden_by
|
||||
? localize(
|
||||
"ui.panel.config.entities.picker.status.hidden"
|
||||
)
|
||||
: localize(
|
||||
"ui.panel.config.entities.picker.status.unmanageable"
|
||||
)
|
||||
}
|
||||
</ha-tooltip>
|
||||
</div>`
|
||||
: "\u2014",
|
||||
},
|
||||
created_at: getCreatedAtTableColumn(localize, hass),
|
||||
modified_at: getModifiedAtTableColumn(localize, hass),
|
||||
available: {
|
||||
title: localize("ui.panel.config.entities.picker.headers.availability"),
|
||||
sortable: true,
|
||||
groupable: true,
|
||||
hidden: true,
|
||||
},
|
||||
visible: {
|
||||
title: localize("ui.panel.config.entities.picker.headers.visibility"),
|
||||
sortable: true,
|
||||
groupable: true,
|
||||
hidden: true,
|
||||
},
|
||||
enabled: {
|
||||
title: localize("ui.panel.config.entities.picker.headers.enabled"),
|
||||
sortable: true,
|
||||
groupable: true,
|
||||
hidden: true,
|
||||
},
|
||||
labels: getLabelsTableColumn(),
|
||||
assistants: getAssistantsTableColumn(
|
||||
localize,
|
||||
hass,
|
||||
availableAssistants,
|
||||
entitiesToCheck
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
private _stateEntities = memoize(
|
||||
(
|
||||
states: HomeAssistant["states"],
|
||||
entities: EntityRegistryEntry[],
|
||||
sources: EntitySources | undefined,
|
||||
exposedEntities: Record<string, ExposeEntitySettings> | undefined
|
||||
): StateEntity[] => {
|
||||
const regEntityIds = new Set(entities.map((entity) => entity.entity_id));
|
||||
return Object.keys(states)
|
||||
.filter((entityId) => !regEntityIds.has(entityId))
|
||||
.map((entityId) => ({
|
||||
name: computeStateName(states[entityId]),
|
||||
entity_id: entityId,
|
||||
platform: sources?.[entityId]?.domain || computeDomain(entityId),
|
||||
disabled_by: null,
|
||||
hidden_by: null,
|
||||
area_id: null,
|
||||
config_entry_id: null,
|
||||
config_subentry_id: null,
|
||||
device_id: null,
|
||||
icon: null,
|
||||
readonly: true,
|
||||
selectable: false,
|
||||
entity_category: null,
|
||||
has_entity_name: false,
|
||||
options: Object.fromEntries(
|
||||
Object.keys(voiceAssistants).map((vaId) => [
|
||||
vaId,
|
||||
{ should_expose: exposedEntities?.[entityId]?.[vaId] },
|
||||
])
|
||||
),
|
||||
labels: [],
|
||||
categories: {},
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
private _filteredEntitiesAndDomains = memoize(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
entities: StateEntity[],
|
||||
states: HomeAssistant["states"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
entitiesWithoutUniqueId: StateEntity[],
|
||||
filters: DataTableFiltersValues,
|
||||
filteredItems: DataTableFiltersItems,
|
||||
entries: ConfigEntry[] | undefined,
|
||||
labelReg: LabelRegistryEntry[] | undefined,
|
||||
entitySources: EntitySources | undefined,
|
||||
exposedEntities: Record<string, ExposeEntitySettings> | undefined
|
||||
) => {
|
||||
const result: EntityRow[] = [];
|
||||
const stateFilters = filters["ha-filter-states"] as string[];
|
||||
const showEnabled =
|
||||
!stateFilters?.length || stateFilters.includes("enabled");
|
||||
const showDisabled =
|
||||
!stateFilters?.length || stateFilters.includes("disabled");
|
||||
const showVisible =
|
||||
!stateFilters?.length || stateFilters.includes("visible");
|
||||
const showHidden =
|
||||
!stateFilters?.length || stateFilters.includes("hidden");
|
||||
const showAvailable =
|
||||
!stateFilters?.length || stateFilters.includes("available");
|
||||
const showUnavailable =
|
||||
!stateFilters?.length || stateFilters.includes("unavailable");
|
||||
const showRestored =
|
||||
!stateFilters?.length || stateFilters.includes("restored");
|
||||
const showReadOnly =
|
||||
!stateFilters?.length || stateFilters.includes("readonly");
|
||||
|
||||
let filteredEntities = entities.concat(entitiesWithoutUniqueId);
|
||||
let filteredConfigEntry: ConfigEntry | undefined;
|
||||
const filteredDomains = new Set<string>();
|
||||
|
||||
Object.entries(filters).forEach(([key, filter]) => {
|
||||
if (!Array.isArray(filter) || !filter.length) {
|
||||
return;
|
||||
}
|
||||
if (key === "config_entry") {
|
||||
filteredEntities = filteredEntities.filter(
|
||||
(entity) =>
|
||||
entity.config_entry_id && filter.includes(entity.config_entry_id)
|
||||
);
|
||||
const configEntries = entries?.filter((entry) =>
|
||||
filter.includes(entry.entry_id)
|
||||
);
|
||||
configEntries?.forEach((entry) => filteredDomains.add(entry.domain));
|
||||
if (configEntries?.length === 1) {
|
||||
filteredConfigEntry = configEntries[0];
|
||||
}
|
||||
} else if (key === "sub_entry") {
|
||||
if (
|
||||
!Array.isArray(filters.config_entry) ||
|
||||
filters.config_entry.length !== 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
filteredEntities = filteredEntities.filter(
|
||||
(entity) =>
|
||||
entity.config_subentry_id &&
|
||||
filter.includes(entity.config_subentry_id)
|
||||
);
|
||||
} else if (key === "ha-filter-integrations") {
|
||||
const entryIds =
|
||||
entries
|
||||
?.filter((entry) => filter.includes(entry.domain))
|
||||
.map((entry) => entry.entry_id) || [];
|
||||
filteredEntities = filteredEntities.filter(
|
||||
(entity) =>
|
||||
filter.includes(
|
||||
entitySources?.[entity.entity_id]?.domain ?? ""
|
||||
) ||
|
||||
filter.includes(entity.platform) ||
|
||||
(entity.config_entry_id &&
|
||||
entryIds.includes(entity.config_entry_id))
|
||||
);
|
||||
filter.forEach((domain) => filteredDomains.add(domain));
|
||||
} else if (key === "ha-filter-domains") {
|
||||
filteredEntities = filteredEntities.filter((entity) =>
|
||||
filter.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
} else if (key === "ha-filter-labels") {
|
||||
filteredEntities = filteredEntities.filter((entity) =>
|
||||
entity.labels.some((lbl) => filter.includes(lbl))
|
||||
);
|
||||
} else if (key === "ha-filter-voice-assistants") {
|
||||
filteredEntities = filteredEntities.filter((entity) =>
|
||||
filter.some((va) => exposedEntities?.[entity.entity_id]?.[va])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Object.values(filteredItems).forEach((items) => {
|
||||
if (items) {
|
||||
filteredEntities = filteredEntities.filter((entity) =>
|
||||
items.has(entity.entity_id)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const duplicatedDevicesNames = getDuplicatedDeviceNames(devices);
|
||||
for (const entry of filteredEntities) {
|
||||
const entity = states[entry.entity_id];
|
||||
const unavailable = entity?.state === UNAVAILABLE;
|
||||
const restored = entity?.attributes.restored === true;
|
||||
const device = entry.device_id ? devices[entry.device_id] : undefined;
|
||||
const areaId = entry.area_id || device?.area_id;
|
||||
const area = areaId ? areas[areaId] : undefined;
|
||||
const hidden = !!entry.hidden_by;
|
||||
const disabled = !!entry.disabled_by;
|
||||
const readonly = entry.readonly;
|
||||
const available = entity?.state && entity.state !== UNAVAILABLE;
|
||||
if (!(
|
||||
(showAvailable && available) ||
|
||||
(showUnavailable && unavailable) ||
|
||||
(showRestored && restored) ||
|
||||
(showVisible && !hidden) ||
|
||||
(showHidden && hidden) ||
|
||||
(showDisabled && disabled) ||
|
||||
(showEnabled && !disabled) ||
|
||||
(showReadOnly && readonly)
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
const labelsEntries = (entry.labels || [])
|
||||
.map((lbl) => labelReg?.find((label) => label.label_id === lbl))
|
||||
.filter((lbl): lbl is LabelRegistryEntry => lbl !== undefined);
|
||||
const entityName = computeEntityEntryName(
|
||||
entry as EntityRegistryEntry,
|
||||
devices,
|
||||
entity
|
||||
);
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
const deviceFullName = deviceName
|
||||
? duplicatedDevicesNames.has(deviceName) && areaName
|
||||
? `${deviceName} (${areaName})`
|
||||
: deviceName
|
||||
: undefined;
|
||||
const assistants = Object.keys(voiceAssistants).filter(
|
||||
(va) => exposedEntities?.[entry.entity_id]?.[va]
|
||||
);
|
||||
result.push({
|
||||
...entry,
|
||||
name: entityName || deviceName || entry.entity_id,
|
||||
device: deviceName,
|
||||
area: areaName,
|
||||
device_full: deviceFullName,
|
||||
unavailable,
|
||||
restored,
|
||||
localized_platform: domainToName(localize, entry.platform),
|
||||
domain: domainToName(localize, computeDomain(entry.entity_id)),
|
||||
assistants,
|
||||
assistants_sortable_key: getAssistantsSortableKey(assistants),
|
||||
status: restored
|
||||
? localize("ui.panel.config.entities.picker.status.not_provided")
|
||||
: unavailable
|
||||
? localize("ui.panel.config.entities.picker.status.unavailable")
|
||||
: disabled
|
||||
? localize("ui.panel.config.entities.picker.status.disabled")
|
||||
: hidden
|
||||
? localize("ui.panel.config.entities.picker.status.hidden")
|
||||
: readonly
|
||||
? localize(
|
||||
"ui.panel.config.entities.picker.status.unmanageable"
|
||||
)
|
||||
: localize(
|
||||
"ui.panel.config.entities.picker.status.available"
|
||||
),
|
||||
label_entries: labelsEntries,
|
||||
available: unavailable
|
||||
? localize("ui.panel.config.entities.picker.status.unavailable")
|
||||
: localize("ui.panel.config.entities.picker.status.available"),
|
||||
enabled: disabled
|
||||
? localize("ui.panel.config.entities.picker.status.disabled")
|
||||
: localize("ui.panel.config.entities.picker.status.enabled"),
|
||||
visible: hidden
|
||||
? localize("ui.panel.config.entities.picker.status.hidden")
|
||||
: localize("ui.panel.config.entities.picker.status.visible"),
|
||||
});
|
||||
}
|
||||
return { filteredEntities: result, filteredConfigEntry, filteredDomains };
|
||||
}
|
||||
);
|
||||
|
||||
private _getRows() {
|
||||
return this._filteredEntitiesAndDomains(
|
||||
this._i18n.localize,
|
||||
this._entities!,
|
||||
this._entityStates,
|
||||
this._devices,
|
||||
this._areas,
|
||||
this._stateEntities(
|
||||
this._entityStates,
|
||||
this._entities!,
|
||||
this._entitySources,
|
||||
this._exposedEntities
|
||||
),
|
||||
this.filterValues,
|
||||
this._filteredItems,
|
||||
this._entries,
|
||||
this._labels,
|
||||
this._entitySources,
|
||||
this._exposedEntities
|
||||
);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._entities) {
|
||||
return html`<hass-loading-screen></hass-loading-screen>`;
|
||||
}
|
||||
const { filteredEntities } = this._getRows();
|
||||
return html`
|
||||
<hass-tabs-subpage-data-table
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.backPath=${this.backPath}
|
||||
.route=${this.route}
|
||||
.tabs=${this.tabs}
|
||||
.columns=${this._columns(this._i18n.localize, this.hass, getAvailableAssistants(this.cloudStatus, this.hass), filteredEntities)}
|
||||
.data=${filteredEntities}
|
||||
.searchLabel=${this._i18n.localize("ui.panel.config.entities.picker.search", { number: filteredEntities.length })}
|
||||
has-filters
|
||||
.filters=${Object.values(this.filterValues).filter((filter) => (Array.isArray(filter) ? filter.length : filter && Object.values(filter).some((val) => (Array.isArray(val) ? val.length : val)))).length}
|
||||
.selectable=${this.selectable}
|
||||
.selected=${this.selected}
|
||||
.initialGroupColumn=${this.initialGroupColumn}
|
||||
.initialCollapsedGroups=${this.initialCollapsedGroups}
|
||||
.initialSorting=${this.initialSorting}
|
||||
.columnOrder=${this.columnOrder}
|
||||
.hiddenColumns=${this.hiddenColumns}
|
||||
.clickable=${this.clickable}
|
||||
@clear-filter=${this._clearFilter}
|
||||
.filter=${this.filter}
|
||||
@row-click=${this._rowClicked}
|
||||
id="entity_id"
|
||||
.hasFab=${this.hasFab}
|
||||
class=${this.narrow ? "narrow" : ""}
|
||||
>
|
||||
<slot name="toolbar-icon" slot="toolbar-icon"></slot>
|
||||
<slot name="selection-bar" slot="selection-bar"></slot>
|
||||
<slot name="top-header" slot="top-header"></slot>
|
||||
<slot name="fab" slot="fab"></slot>
|
||||
${
|
||||
Array.isArray(this.filterValues.config_entry) &&
|
||||
this.filterValues.config_entry.length
|
||||
? html`<ha-alert slot="filter-pane">
|
||||
${this._i18n.localize("ui.panel.config.entities.picker.filtering_by_config_entry")}
|
||||
${this._entries?.find((entry) => entry.entry_id === this.filterValues.config_entry![0])?.title || this.filterValues.config_entry[0]}${
|
||||
this.filterValues.config_entry.length === 1 &&
|
||||
Array.isArray(this.filterValues.sub_entry) &&
|
||||
this.filterValues.sub_entry.length
|
||||
? html` (${this._subEntries?.find((entry) => entry.subentry_id === this.filterValues.sub_entry![0])?.title || this.filterValues.sub_entry[0]})`
|
||||
: nothing
|
||||
}
|
||||
</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
<ha-filter-floor-areas
|
||||
type="entity"
|
||||
.value=${guard(
|
||||
[this.filterValues["ha-filter-floor-areas"]],
|
||||
() => this.filterValues["ha-filter-floor-areas"]
|
||||
)}
|
||||
@data-table-filter-changed=${this._filterChanged}
|
||||
slot="filter-pane"
|
||||
.expanded=${this._expandedFilter === "ha-filter-floor-areas"}
|
||||
.narrow=${this.narrow}
|
||||
@expanded-changed=${this._filterExpanded}
|
||||
></ha-filter-floor-areas>
|
||||
<ha-filter-devices
|
||||
type="entity"
|
||||
.value=${guard(
|
||||
[this.filterValues["ha-filter-devices"]],
|
||||
() => this.filterValues["ha-filter-devices"]
|
||||
)}
|
||||
@data-table-filter-changed=${this._filterChanged}
|
||||
slot="filter-pane"
|
||||
.expanded=${this._expandedFilter === "ha-filter-devices"}
|
||||
.narrow=${this.narrow}
|
||||
@expanded-changed=${this._filterExpanded}
|
||||
></ha-filter-devices>
|
||||
<ha-filter-domains
|
||||
.value=${this.filterValues["ha-filter-domains"]}
|
||||
@data-table-filter-changed=${this._filterChanged}
|
||||
slot="filter-pane"
|
||||
.expanded=${this._expandedFilter === "ha-filter-domains"}
|
||||
.narrow=${this.narrow}
|
||||
@expanded-changed=${this._filterExpanded}
|
||||
></ha-filter-domains>
|
||||
<ha-filter-integrations
|
||||
.value=${this.filterValues["ha-filter-integrations"]}
|
||||
@data-table-filter-changed=${this._filterChanged}
|
||||
slot="filter-pane"
|
||||
.expanded=${this._expandedFilter === "ha-filter-integrations"}
|
||||
.narrow=${this.narrow}
|
||||
@expanded-changed=${this._filterExpanded}
|
||||
></ha-filter-integrations>
|
||||
<ha-filter-states
|
||||
.label=${this._i18n.localize("ui.panel.config.entities.picker.headers.status")}
|
||||
.value=${this.filterValues["ha-filter-states"]}
|
||||
.states=${this._states(this._i18n.localize)}
|
||||
@data-table-filter-changed=${this._filterChanged}
|
||||
slot="filter-pane"
|
||||
.expanded=${this._expandedFilter === "ha-filter-states"}
|
||||
.narrow=${this.narrow}
|
||||
@expanded-changed=${this._filterExpanded}
|
||||
></ha-filter-states>
|
||||
<ha-filter-labels
|
||||
.value=${this.filterValues["ha-filter-labels"]}
|
||||
@data-table-filter-changed=${this._filterChanged}
|
||||
slot="filter-pane"
|
||||
.expanded=${this._expandedFilter === "ha-filter-labels"}
|
||||
.narrow=${this.narrow}
|
||||
@expanded-changed=${this._filterExpanded}
|
||||
></ha-filter-labels>
|
||||
<ha-filter-voice-assistants
|
||||
.value=${this.filterValues["ha-filter-voice-assistants"]}
|
||||
@data-table-filter-changed=${this._filterChanged}
|
||||
slot="filter-pane"
|
||||
.expanded=${this._expandedFilter === "ha-filter-voice-assistants"}
|
||||
.narrow=${this.narrow}
|
||||
@expanded-changed=${this._filterExpanded}
|
||||
></ha-filter-voice-assistants>
|
||||
</hass-tabs-subpage-data-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private _filterExpanded(
|
||||
ev: HASSDomEvent<{ expanded: boolean }> &
|
||||
HASSDomCurrentTargetEvent<HTMLElement>
|
||||
) {
|
||||
if (ev.detail.expanded) {
|
||||
this._expandedFilter = ev.currentTarget.localName;
|
||||
} else if (this._expandedFilter === ev.currentTarget.localName) {
|
||||
this._expandedFilter = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _filterChanged(
|
||||
ev: HASSDomEvent<DataTableFilter> & HASSDomCurrentTargetEvent<HTMLElement>
|
||||
) {
|
||||
ev.stopPropagation();
|
||||
const type = ev.currentTarget.localName;
|
||||
this._itemFilterValues[type] = ev.detail.value;
|
||||
this._filteredItems = {
|
||||
...this._filteredItems,
|
||||
[type]: (
|
||||
Array.isArray(ev.detail.value)
|
||||
? ev.detail.value.length
|
||||
: ev.detail.value &&
|
||||
Object.values(ev.detail.value).some((values) => values.length)
|
||||
)
|
||||
? ev.detail.items
|
||||
: undefined,
|
||||
};
|
||||
this.filterValues = { ...this.filterValues, [type]: ev.detail.value };
|
||||
fireEvent(this, "entity-table-filters-changed", {
|
||||
value: this.filterValues,
|
||||
});
|
||||
}
|
||||
|
||||
private _clearFilter() {
|
||||
this._filteredItems = {};
|
||||
this._itemFilterValues = {};
|
||||
this.filterValues = {};
|
||||
fireEvent(this, "entity-table-filters-changed", {
|
||||
value: this.filterValues,
|
||||
});
|
||||
}
|
||||
|
||||
private _rowClicked(ev: HASSDomEvent<RowClickedEvent>) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "row-click", ev.detail, { bubbles: false });
|
||||
}
|
||||
|
||||
private async _loadSubEntries(entryId: string) {
|
||||
const entries = await getSubEntries(this.hass, entryId);
|
||||
if (this._subEntryConfigEntry === entryId) {
|
||||
this._subEntries = entries;
|
||||
}
|
||||
}
|
||||
|
||||
private _fetchExposedEntities = async () => {
|
||||
try {
|
||||
this._exposedEntities = (
|
||||
await listExposedEntities(this.hass)
|
||||
).exposed_entities;
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to fetch exposed entities", err);
|
||||
this._exposedEntities = {};
|
||||
}
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
hass-tabs-subpage-data-table {
|
||||
--data-table-row-height: 60px;
|
||||
}
|
||||
hass-tabs-subpage-data-table.narrow {
|
||||
--data-table-row-height: 72px;
|
||||
}
|
||||
hass-loading-screen {
|
||||
--app-header-background-color: var(--sidebar-background-color);
|
||||
--app-header-text-color: var(--sidebar-text-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"entity-table-filter-context-changed": EntityTableFilterContext;
|
||||
"entity-table-filters-changed": { value: DataTableFiltersValues };
|
||||
}
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-entity-table": HaEntityTable;
|
||||
}
|
||||
}
|
||||
@@ -232,6 +232,12 @@ class HaPanelConfig extends HassRouterPage {
|
||||
import("./integrations/integration-panels/serial/serial-config-dashboard"),
|
||||
waitForReady: true,
|
||||
},
|
||||
modbus: {
|
||||
tag: "modbus-config-dashboard",
|
||||
load: () =>
|
||||
import("./integrations/integration-panels/modbus/modbus-config-dashboard"),
|
||||
waitForReady: true,
|
||||
},
|
||||
dhcp: {
|
||||
tag: "dhcp-config-panel",
|
||||
load: () =>
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
import {
|
||||
mdiCableData,
|
||||
mdiLan,
|
||||
mdiPuzzle,
|
||||
mdiRefresh,
|
||||
mdiTransitConnectionVariant,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
|
||||
import { caseInsensitiveStringCompare } from "../../../../../common/string/compare";
|
||||
import "../../../../../components/ha-alert";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/ha-icon-next";
|
||||
import "../../../../../components/ha-md-list";
|
||||
import "../../../../../components/ha-md-list-item";
|
||||
import "../../../../../components/ha-spinner";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import type { ConfigEntry } from "../../../../../data/config_entries";
|
||||
import { getConfigEntries } from "../../../../../data/config_entries";
|
||||
import { domainToName } from "../../../../../data/integration";
|
||||
import type { ModbusConnection } from "../../../../../data/modbus";
|
||||
import {
|
||||
listModbusConnections,
|
||||
modbusEndpointTarget,
|
||||
modbusSerialDevice,
|
||||
modbusUnitCount,
|
||||
} from "../../../../../data/modbus";
|
||||
import "../../../../../layouts/hass-subpage";
|
||||
import { panelIsReady } from "../../../../../layouts/panel-ready";
|
||||
import { haStyle } from "../../../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../../../types";
|
||||
import { brandsUrl } from "../../../../../util/brands-url";
|
||||
|
||||
const TRANSPORTS = ["tcp", "udp", "serial"] as const;
|
||||
|
||||
type ModbusTransport = (typeof TRANSPORTS)[number];
|
||||
|
||||
const isKnownTransport = (transport: string): transport is ModbusTransport =>
|
||||
(TRANSPORTS as readonly string[]).includes(transport);
|
||||
|
||||
interface ConnectionHolder {
|
||||
entryId: string;
|
||||
entry?: ConfigEntry;
|
||||
units: number[];
|
||||
}
|
||||
|
||||
interface ConnectionListItem {
|
||||
icon: string;
|
||||
primary: string;
|
||||
transport: string;
|
||||
state: string;
|
||||
serialDevice?: string;
|
||||
holders: ConnectionHolder[];
|
||||
connection: ModbusConnection;
|
||||
}
|
||||
|
||||
@customElement("modbus-config-dashboard")
|
||||
export class ModbusConfigDashboard extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
|
||||
|
||||
@state() private _connections?: ModbusConnection[];
|
||||
|
||||
@state() private _entries?: Record<string, ConfigEntry>;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
protected async firstUpdated(): Promise<void> {
|
||||
await this._fetchConnections();
|
||||
await panelIsReady(this);
|
||||
}
|
||||
|
||||
private async _fetchConnections(): Promise<void> {
|
||||
try {
|
||||
const [connections, entries] = await Promise.all([
|
||||
listModbusConnections(this.hass),
|
||||
getConfigEntries(this.hass),
|
||||
]);
|
||||
this._connections = connections;
|
||||
this._entries = Object.fromEntries(
|
||||
entries.map((entry) => [entry.entry_id, entry])
|
||||
);
|
||||
this._error = undefined;
|
||||
} catch (err: any) {
|
||||
this._error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
private _transportName(transport: string): string {
|
||||
return isKnownTransport(transport)
|
||||
? this.hass.localize(`ui.panel.config.modbus.transport.${transport}`)
|
||||
: transport;
|
||||
}
|
||||
|
||||
// A serial port is open or closed. Opening one says nothing about whether a
|
||||
// device answers on the wire, which a network connection's handshake does.
|
||||
private _stateName(connected: boolean, serial: boolean): string {
|
||||
if (serial) {
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.modbus.state_${connected ? "open" : "closed"}`
|
||||
);
|
||||
}
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.modbus.state_${connected ? "connected" : "not_connected"}`
|
||||
);
|
||||
}
|
||||
|
||||
private _connectionListItem(
|
||||
connection: ModbusConnection,
|
||||
entries: Record<string, ConfigEntry>
|
||||
): ConnectionListItem {
|
||||
const [transport] = connection.endpoint;
|
||||
const serialDevice = modbusSerialDevice(connection.endpoint);
|
||||
|
||||
return {
|
||||
icon: serialDevice ? mdiCableData : mdiLan,
|
||||
primary: modbusEndpointTarget(connection.endpoint),
|
||||
transport: this._transportName(transport),
|
||||
state: this._stateName(connection.connected, serialDevice !== undefined),
|
||||
serialDevice,
|
||||
holders: Object.entries(connection.units).map(([entryId, units]) => ({
|
||||
entryId,
|
||||
entry: entries[entryId],
|
||||
units,
|
||||
})),
|
||||
connection,
|
||||
};
|
||||
}
|
||||
|
||||
private _sortedConnections = memoizeOne(
|
||||
(
|
||||
connections: ModbusConnection[],
|
||||
entries: Record<string, ConfigEntry>,
|
||||
_localize: HomeAssistant["localize"],
|
||||
language: string
|
||||
): ConnectionListItem[] =>
|
||||
connections
|
||||
.map((connection) => this._connectionListItem(connection, entries))
|
||||
.sort((a, b) =>
|
||||
caseInsensitiveStringCompare(a.primary, b.primary, language)
|
||||
)
|
||||
);
|
||||
|
||||
private _renderUnits(holder: ConnectionHolder): TemplateResult {
|
||||
return html`
|
||||
<div slot="supporting-text">
|
||||
${this.hass.localize("ui.panel.config.modbus.units", {
|
||||
count: holder.units.length,
|
||||
ids: holder.units.join(", "),
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderHolder(holder: ConnectionHolder): TemplateResult {
|
||||
// An entry removed while the connection was listed has nowhere to link to
|
||||
if (!holder.entry) {
|
||||
return html`
|
||||
<ha-md-list-item class="holder">
|
||||
<ha-svg-icon slot="start" .path=${mdiPuzzle}></ha-svg-icon>
|
||||
<div slot="headline">${holder.entryId}</div>
|
||||
${this._renderUnits(holder)}
|
||||
</ha-md-list-item>
|
||||
`;
|
||||
}
|
||||
|
||||
const { domain, title } = holder.entry;
|
||||
|
||||
return html`
|
||||
<ha-md-list-item
|
||||
type="link"
|
||||
class="holder"
|
||||
href=${`/config/integrations/integration/${domain}#config_entry=${holder.entryId}`}
|
||||
>
|
||||
<img
|
||||
slot="start"
|
||||
.src=${brandsUrl(
|
||||
{
|
||||
domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
alt=${domain}
|
||||
/>
|
||||
<div slot="headline">
|
||||
${title || domainToName(this.hass.localize, domain)}
|
||||
</div>
|
||||
${this._renderUnits(holder)}
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderSerialPortLink(): TemplateResult {
|
||||
return html`
|
||||
<ha-md-list-item type="link" class="holder" href="/config/serial">
|
||||
<ha-svg-icon slot="start" .path=${mdiCableData}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize("ui.panel.config.modbus.view_serial_port")}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
`;
|
||||
}
|
||||
|
||||
// A closed link is not a fault: the library opens one when an integration
|
||||
// next polls, and a device may drop an idle one in the meantime
|
||||
private _renderState(item: ConnectionListItem): TemplateResult {
|
||||
return html`
|
||||
<div slot="end" class="state">
|
||||
<span
|
||||
class="dot ${item.connection.connected ? "online" : "offline"}"
|
||||
></span>
|
||||
${item.state}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderConnectionItem(item: ConnectionListItem): TemplateResult {
|
||||
return html`
|
||||
<ha-md-list-item class="connection">
|
||||
<ha-svg-icon slot="start" .path=${item.icon}></ha-svg-icon>
|
||||
<div slot="headline">${item.primary}</div>
|
||||
<div slot="supporting-text">${item.transport}</div>
|
||||
${this._renderState(item)}
|
||||
</ha-md-list-item>
|
||||
${item.holders.map((holder) => this._renderHolder(holder))}
|
||||
${
|
||||
item.serialDevice && isComponentLoaded(this.hass.config, "usb")
|
||||
? this._renderSerialPortLink()
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderConnectionsCard(items: ConnectionListItem[]): TemplateResult {
|
||||
return html`
|
||||
<ha-card class="connections">
|
||||
<div class="card-header">
|
||||
${this.hass.localize("ui.panel.config.modbus.connections")}
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="description">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.modbus.connections_description"
|
||||
)}
|
||||
</div>
|
||||
<ha-md-list>
|
||||
${items.map((item) => this._renderConnectionItem(item))}
|
||||
</ha-md-list>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
// No health verdict: the summary is a count, since a closed link is normal
|
||||
private _renderStatusCard(items: ConnectionListItem[]): TemplateResult {
|
||||
return html`
|
||||
<ha-card class="status">
|
||||
<div class="card-content">
|
||||
<div class="heading">
|
||||
<div class="icon">
|
||||
<ha-svg-icon .path=${mdiTransitConnectionVariant}></ha-svg-icon>
|
||||
</div>
|
||||
<div class="details">
|
||||
${this.hass.localize("ui.panel.config.modbus.status_summary", {
|
||||
units: items.reduce(
|
||||
(total, item) => total + modbusUnitCount(item.connection),
|
||||
0
|
||||
),
|
||||
total: items.length,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize("ui.panel.config.modbus.title")}
|
||||
back-path="/config/connectivity"
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="toolbar-icon"
|
||||
.label=${this.hass.localize("ui.common.refresh")}
|
||||
.path=${mdiRefresh}
|
||||
@click=${this._handleRefresh}
|
||||
></ha-icon-button>
|
||||
<div class="container">${this._renderContent()}</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderContent(): TemplateResult {
|
||||
if (this._error !== undefined) {
|
||||
return html`
|
||||
<ha-alert
|
||||
alert-type="error"
|
||||
.title=${this.hass.localize("ui.panel.config.modbus.loading_error")}
|
||||
>
|
||||
${this._error}
|
||||
</ha-alert>
|
||||
`;
|
||||
}
|
||||
|
||||
if (!this._connections || !this._entries) {
|
||||
return html`
|
||||
<div class="loading">
|
||||
<ha-spinner></ha-spinner>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const items = this._sortedConnections(
|
||||
this._connections,
|
||||
this._entries,
|
||||
this.hass.localize,
|
||||
this.hass.locale.language
|
||||
);
|
||||
|
||||
if (!items.length) {
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<div class="empty">
|
||||
${this.hass.localize("ui.panel.config.modbus.no_connections")}
|
||||
<br />
|
||||
<small>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.modbus.no_connections_description"
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
${this._renderStatusCard(items)} ${this._renderConnectionsCard(items)}
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleRefresh(): void {
|
||||
this._fetchConnections();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
.container {
|
||||
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
|
||||
}
|
||||
|
||||
ha-card,
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin: 0px auto var(--ha-space-4);
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
ha-card:first-child {
|
||||
margin-top: var(--ha-space-6);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--ha-space-12) 0;
|
||||
}
|
||||
|
||||
ha-card.connections {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
ha-card.connections .card-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ha-card.connections .card-header {
|
||||
padding-bottom: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.status div.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.status div.heading .icon {
|
||||
position: relative;
|
||||
border-radius: var(--ha-border-radius-2xl);
|
||||
width: var(--ha-space-10);
|
||||
height: var(--ha-space-10);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
--icon-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.status div.heading .icon::before {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--icon-color);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.status div.heading .icon ha-svg-icon {
|
||||
color: var(--icon-color);
|
||||
width: var(--ha-space-6);
|
||||
height: var(--ha-space-6);
|
||||
}
|
||||
|
||||
.status div.heading .details {
|
||||
font-size: var(--ha-font-size-xl);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.status small {
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
letter-spacing: 0.25px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
ha-md-list {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ha-md-list-item {
|
||||
--md-list-item-top-space: var(--ha-space-2);
|
||||
--md-list-item-bottom-space: var(--ha-space-2);
|
||||
--md-list-item-one-line-container-height: 0;
|
||||
--md-list-item-two-line-container-height: 0;
|
||||
--md-list-item-three-line-container-height: 0;
|
||||
}
|
||||
|
||||
ha-md-list-item.connection:not(:first-child) {
|
||||
border-top: 1px solid var(--divider-color);
|
||||
margin-top: var(--ha-space-2);
|
||||
--md-list-item-top-space: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: var(--ha-space-2);
|
||||
font-size: var(--ha-font-size-m);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.state .dot {
|
||||
width: var(--ha-space-2);
|
||||
height: var(--ha-space-2);
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
background-color: var(--disabled-text-color);
|
||||
}
|
||||
|
||||
.state .dot.online {
|
||||
background-color: var(--success-color);
|
||||
}
|
||||
|
||||
ha-md-list-item.holder {
|
||||
--md-list-item-leading-space: var(--ha-space-14);
|
||||
}
|
||||
|
||||
ha-md-list-item.holder img[slot="start"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.description {
|
||||
padding: 0 var(--ha-space-4) var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: var(--ha-space-4);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.empty small {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"modbus-config-dashboard": ModbusConfigDashboard;
|
||||
}
|
||||
}
|
||||
+47
-1
@@ -8,6 +8,7 @@ import {
|
||||
mdiPowerPlugOff,
|
||||
mdiPuzzle,
|
||||
mdiRefresh,
|
||||
mdiTransitConnectionVariant,
|
||||
mdiUsb,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
@@ -29,6 +30,10 @@ import {
|
||||
domainToName,
|
||||
getConfigPanelPath,
|
||||
} from "../../../../../data/integration";
|
||||
import {
|
||||
listModbusConnections,
|
||||
modbusSerialDevice,
|
||||
} from "../../../../../data/modbus";
|
||||
import type {
|
||||
SerialPort,
|
||||
SerialPortConsumer,
|
||||
@@ -102,6 +107,8 @@ export class SerialConfigDashboard extends LitElement {
|
||||
|
||||
@state() private _ports?: SerialPortUsage[];
|
||||
|
||||
@state() private _modbusDevices?: Set<string>;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
protected async firstUpdated(): Promise<void> {
|
||||
@@ -111,13 +118,36 @@ export class SerialConfigDashboard extends LitElement {
|
||||
|
||||
private async _fetchPorts(): Promise<void> {
|
||||
try {
|
||||
this._ports = await listSerialPortsWithUsage(this.hass);
|
||||
const [ports, modbusConnections] = await Promise.all([
|
||||
listSerialPortsWithUsage(this.hass),
|
||||
// Modbus only annotates the ports; failing to reach it is not an error
|
||||
isComponentLoaded(this.hass.config, "modbus")
|
||||
? listModbusConnections(this.hass).catch(() => [])
|
||||
: [],
|
||||
]);
|
||||
this._ports = ports;
|
||||
this._modbusDevices = new Set(
|
||||
modbusConnections
|
||||
.map((connection) => modbusSerialDevice(connection.endpoint))
|
||||
.filter((device) => device !== undefined)
|
||||
);
|
||||
this._error = undefined;
|
||||
} catch (err: any) {
|
||||
this._error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Modbus keeps the device path it was configured with verbatim, which may be
|
||||
// an alias of the scanned path, so both are matched
|
||||
private _usedByModbus(port: SerialPort): boolean {
|
||||
return (
|
||||
this._modbusDevices !== undefined &&
|
||||
(this._modbusDevices.has(port.device) ||
|
||||
(port.resolved_device !== null &&
|
||||
this._modbusDevices.has(port.resolved_device)))
|
||||
);
|
||||
}
|
||||
|
||||
private _portListItem(
|
||||
port: SerialPortUsage,
|
||||
localize: HomeAssistant["localize"]
|
||||
@@ -308,6 +338,21 @@ export class SerialConfigDashboard extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderModbusLink(): TemplateResult {
|
||||
return html`
|
||||
<ha-md-list-item type="link" class="consumer" href="/config/modbus">
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiTransitConnectionVariant}
|
||||
></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize("ui.panel.config.serial.used_by_modbus")}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
`;
|
||||
}
|
||||
|
||||
private _continueFlow(ev: Event): void {
|
||||
showConfigFlowDialog(this, {
|
||||
continueFlowId: (ev.currentTarget as any).flowId,
|
||||
@@ -372,6 +417,7 @@ export class SerialConfigDashboard extends LitElement {
|
||||
}
|
||||
</ha-md-list-item>
|
||||
${item.consumers.map((consumer) => this._renderConsumer(consumer))}
|
||||
${this._usedByModbus(item.port) ? this._renderModbusLink() : nothing}
|
||||
${item.discoveryFlows.map((flow) => this._renderDiscoveryFlow(flow))}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
|
||||
const entities = this._filterEntities(
|
||||
this.params.exposedEntities,
|
||||
this._registries,
|
||||
this._filter
|
||||
);
|
||||
|
||||
@@ -180,6 +181,7 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
private _filterEntities = memoizeOne(
|
||||
(
|
||||
exposedEntities: Record<string, ExposeEntitySettings>,
|
||||
registries: ContextType<typeof registriesContext>,
|
||||
filter?: string
|
||||
): FilteredEntity[] => {
|
||||
const lowerFilter = filter?.toLowerCase();
|
||||
@@ -196,11 +198,16 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
|
||||
const nameList = computeEntityNameList(
|
||||
entity,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this._registries.entities,
|
||||
this._registries.devices,
|
||||
this._registries.areas,
|
||||
this._registries.floors
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
registries.entities,
|
||||
registries.devices,
|
||||
registries.areas,
|
||||
registries.floors
|
||||
);
|
||||
|
||||
if (!lowerFilter) {
|
||||
@@ -219,13 +226,18 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, deviceName, areaName] = nameList;
|
||||
const [, deviceName, parentDeviceName, areaName] = nameList;
|
||||
|
||||
if (deviceName?.toLowerCase().includes(lowerFilter)) {
|
||||
result.push({ entity, nameList });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parentDeviceName?.toLowerCase().includes(lowerFilter)) {
|
||||
result.push({ entity, nameList });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (areaName?.toLowerCase().includes(lowerFilter)) {
|
||||
result.push({ entity, nameList });
|
||||
continue;
|
||||
@@ -238,14 +250,18 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
|
||||
private _renderItem = (item: FilteredEntity) => {
|
||||
const { entity: entityState, nameList } = item;
|
||||
const [entityName, deviceName, areaName] = nameList;
|
||||
const [entityName, deviceName, parentDeviceName, areaName] = nameList;
|
||||
|
||||
const isRTL = computeRTL(
|
||||
this._i18n.language,
|
||||
this._i18n.translationMetadata.translations
|
||||
);
|
||||
const primary = entityName || deviceName || entityState.entity_id;
|
||||
const context = [areaName, entityName ? deviceName : undefined]
|
||||
const context = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
const showEntityId = this._config?.userData?.showEntityIdPicker;
|
||||
|
||||
@@ -61,8 +61,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _stateItems?: HassEntity[];
|
||||
|
||||
@state() private _activeEntry = "";
|
||||
|
||||
@state() private _canEditCore = false;
|
||||
|
||||
@query("ha-locations-editor") private _map?: HaLocationsEditor;
|
||||
@@ -151,7 +149,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
.hasMeta=${!this.narrow}
|
||||
@request-selected=${this._itemClicked}
|
||||
.value=${entry.id}
|
||||
?selected=${this._activeEntry === entry.id}
|
||||
>
|
||||
<ha-icon .icon=${entry.icon} slot="graphic"></ha-icon>
|
||||
${entry.name}
|
||||
@@ -185,7 +182,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
.value=${stateObject.entity_id}
|
||||
@request-selected=${this._stateItemClicked}
|
||||
?selected=${this._activeEntry === stateObject.entity_id}
|
||||
.noEdit=${
|
||||
stateObject.entity_id !== "zone.home" ||
|
||||
!this._canEditCore
|
||||
@@ -273,7 +269,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
)}
|
||||
@location-updated=${this._locationUpdated}
|
||||
@radius-updated=${this._radiusUpdated}
|
||||
@marker-clicked=${this._markerClicked}
|
||||
></ha-locations-editor>
|
||||
<div class="overflow">${listBox}</div>
|
||||
</div>
|
||||
@@ -365,7 +360,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private async _locationUpdated(ev: CustomEvent) {
|
||||
this._activeEntry = ev.detail.id;
|
||||
if (ev.detail.id === "zone.home" && this._canEditCore) {
|
||||
await saveCoreConfig(this.hass, {
|
||||
latitude: ev.detail.location[0],
|
||||
@@ -384,7 +378,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private async _radiusUpdated(ev: CustomEvent) {
|
||||
this._activeEntry = ev.detail.id;
|
||||
if (ev.detail.id === "zone.home" && this._canEditCore) {
|
||||
await saveCoreConfig(this.hass, {
|
||||
radius: Math.round(ev.detail.radius),
|
||||
@@ -400,10 +393,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _markerClicked(ev: CustomEvent) {
|
||||
this._activeEntry = ev.detail.id;
|
||||
}
|
||||
|
||||
private _createZone() {
|
||||
this._openDialog();
|
||||
}
|
||||
@@ -417,9 +406,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
this._openEditEntry(ev);
|
||||
return;
|
||||
}
|
||||
const entryId: string = (ev.currentTarget! as any).value;
|
||||
this._zoomZone(entryId);
|
||||
this._activeEntry = entryId;
|
||||
this._zoomZone((ev.currentTarget! as any).value);
|
||||
}
|
||||
|
||||
private _stateItemClicked(ev: CustomEvent) {
|
||||
@@ -435,7 +422,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
this._zoomZone(entryId);
|
||||
this._activeEntry = entryId;
|
||||
}
|
||||
|
||||
private async _zoomZone(id: string) {
|
||||
@@ -478,7 +464,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
if (this.narrow) {
|
||||
return;
|
||||
}
|
||||
this._activeEntry = created.id;
|
||||
await this.updateComplete;
|
||||
await this._map?.updateComplete;
|
||||
this._map?.fitMarker(created.id);
|
||||
@@ -505,7 +490,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
if (this.narrow || !fitMap) {
|
||||
return;
|
||||
}
|
||||
this._activeEntry = entry.id;
|
||||
await this.updateComplete;
|
||||
await this._map?.updateComplete;
|
||||
this._map?.fitMarker(entry.id);
|
||||
|
||||
@@ -270,6 +270,7 @@ class HaPanelHistory extends LitElement {
|
||||
.endTime=${this._endDate}
|
||||
.narrow=${this.narrow}
|
||||
sync-charts
|
||||
inside-labels
|
||||
>
|
||||
</state-history-charts>
|
||||
`
|
||||
@@ -812,7 +813,13 @@ class HaPanelHistory extends LitElement {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden auto;
|
||||
padding: 16px;
|
||||
padding: var(--ha-space-4);
|
||||
}
|
||||
|
||||
/* Narrow screens: keep the vertical rhythm but give the charts the
|
||||
horizontal space back. */
|
||||
:host([narrow]) .results {
|
||||
padding-inline: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.progress-wrapper {
|
||||
|
||||
@@ -41,8 +41,8 @@ export const isRunRow = (item: LogbookEntry): boolean =>
|
||||
classifyLogbookEntry(item) === "automation";
|
||||
|
||||
// How much naming detail an entity row shows, from least to most. The value is
|
||||
// the broadest part shown: `none` (name hidden), `entity`, `device` (device ▸
|
||||
// entity), `area` (area ▸ device ▸ entity).
|
||||
// the broadest part shown: `none` (name hidden), `entity`, `device` (parent
|
||||
// device ▸ device ▸ entity), `area` (area ▸ parent device ▸ device ▸ entity).
|
||||
export type LogbookNameDetail = "none" | "entity" | "device" | "area";
|
||||
|
||||
export interface EntityDisplay {
|
||||
@@ -60,14 +60,20 @@ export const entityDisplay = (
|
||||
return {};
|
||||
}
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
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 primary = entityName || deviceName || entityId;
|
||||
|
||||
@@ -82,11 +88,11 @@ export const entityDisplay = (
|
||||
parts = [];
|
||||
break;
|
||||
case "device":
|
||||
parts = [deviceQualifier];
|
||||
parts = [parentDeviceName, deviceQualifier];
|
||||
break;
|
||||
case "area":
|
||||
default:
|
||||
parts = [areaName, deviceQualifier];
|
||||
parts = [areaName, parentDeviceName, deviceQualifier];
|
||||
}
|
||||
|
||||
const filtered = parts.filter(Boolean) as string[];
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||
import { getConfigEntityId } from "../common/get-config-entity-id";
|
||||
import { checkConditionsMet } from "../common/validate-condition";
|
||||
import { createBadgeElement } from "../create-element/create-badge-element";
|
||||
import { createErrorBadgeConfig } from "../create-element/create-element-base";
|
||||
import type { LovelaceBadge } from "../types";
|
||||
@@ -68,6 +67,7 @@ export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
|
||||
if (this.hass) {
|
||||
this._element.hass = this.hass;
|
||||
}
|
||||
this._element.preview = this.preview;
|
||||
// Update element when the visibility of the badge changes, e.g. custom badge
|
||||
this._element.addEventListener("badge-visibility-changed", (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
@@ -129,11 +129,12 @@ export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changedProps.has("hass")) {
|
||||
if (changedProps.has("hass") || changedProps.has("preview")) {
|
||||
try {
|
||||
if (this.hass) {
|
||||
this._element.hass = this.hass;
|
||||
}
|
||||
this._element.preview = this.preview;
|
||||
} catch (e: any) {
|
||||
this._loadElement(createErrorBadgeConfig(e.message, null));
|
||||
}
|
||||
@@ -165,14 +166,7 @@ export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
|
||||
return;
|
||||
}
|
||||
|
||||
const visible =
|
||||
conditionsMet ??
|
||||
(!this.config?.visibility ||
|
||||
checkConditionsMet(
|
||||
this.config.visibility,
|
||||
this.hass,
|
||||
this._conditionContext
|
||||
));
|
||||
const visible = conditionsMet ?? this._conditionsVisible();
|
||||
this._setElementVisibility(visible);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ export class HuiEntityFilterBadge
|
||||
|
||||
private _elements?: HuiBadge[];
|
||||
|
||||
private _placeholderBadge?: HTMLElement;
|
||||
|
||||
private _configEntities?: EntityFilterEntityConfig[];
|
||||
|
||||
private _oldEntities?: EntityFilterEntityConfig[];
|
||||
@@ -56,6 +58,7 @@ export class HuiEntityFilterBadge
|
||||
this.removeChild(this.lastChild);
|
||||
}
|
||||
this._elements = undefined;
|
||||
this._placeholderBadge = undefined;
|
||||
|
||||
this._configEntities = processConfigEntities(config.entities);
|
||||
this._oldEntities = undefined;
|
||||
@@ -69,6 +72,7 @@ export class HuiEntityFilterBadge
|
||||
protected shouldUpdate(changedProperties: PropertyValues): boolean {
|
||||
if (
|
||||
changedProperties.has("_config") ||
|
||||
changedProperties.has("preview") ||
|
||||
(changedProperties.has("hass") &&
|
||||
this._haveEntitiesChanged(
|
||||
changedProperties.get("hass") as HomeAssistant | undefined
|
||||
@@ -112,33 +116,42 @@ export class HuiEntityFilterBadge
|
||||
});
|
||||
|
||||
if (entitiesList.length === 0) {
|
||||
this.style.display = "none";
|
||||
this._oldEntities = entitiesList;
|
||||
return;
|
||||
}
|
||||
|
||||
const isSame =
|
||||
this._oldEntities &&
|
||||
entitiesList.length === this._oldEntities.length &&
|
||||
entitiesList.every((entity, idx) => entity === this._oldEntities![idx]);
|
||||
if (!this.preview) {
|
||||
this._placeholderBadge = undefined;
|
||||
this.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSame) {
|
||||
this._elements = [];
|
||||
for (const badgeConfig of entitiesList) {
|
||||
const element = document.createElement("hui-badge");
|
||||
element.hass = this.hass;
|
||||
element.preview = this.preview;
|
||||
element.config = {
|
||||
type: "entity",
|
||||
...badgeConfig,
|
||||
};
|
||||
element.load();
|
||||
this._elements.push(element);
|
||||
this._placeholderBadge ??= document.createElement("ha-badge");
|
||||
} else {
|
||||
this._placeholderBadge = undefined;
|
||||
|
||||
const isSame =
|
||||
!changedProperties.has("preview") &&
|
||||
this._oldEntities &&
|
||||
entitiesList.length === this._oldEntities.length &&
|
||||
entitiesList.every((entity, idx) => entity === this._oldEntities![idx]);
|
||||
|
||||
if (!isSame) {
|
||||
this._elements = [];
|
||||
for (const badgeConfig of entitiesList) {
|
||||
const element = document.createElement("hui-badge");
|
||||
element.hass = this.hass;
|
||||
element.preview = this.preview;
|
||||
element.config = {
|
||||
type: "entity",
|
||||
...badgeConfig,
|
||||
};
|
||||
element.load();
|
||||
this._elements.push(element);
|
||||
}
|
||||
this._oldEntities = entitiesList;
|
||||
}
|
||||
this._oldEntities = entitiesList;
|
||||
}
|
||||
|
||||
if (!this._elements) {
|
||||
if (!this._elements && !this._placeholderBadge) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -146,8 +159,12 @@ export class HuiEntityFilterBadge
|
||||
this.removeChild(this.lastChild);
|
||||
}
|
||||
|
||||
for (const element of this._elements) {
|
||||
this.appendChild(element);
|
||||
if (this._placeholderBadge) {
|
||||
this.appendChild(this._placeholderBadge);
|
||||
} else {
|
||||
for (const element of this._elements!) {
|
||||
this.appendChild(element);
|
||||
}
|
||||
}
|
||||
|
||||
this.style.display = "flex";
|
||||
|
||||
@@ -530,14 +530,6 @@ class HuiTemperatureForecastCardFeature
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
border-bottom-right-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container.with-labels {
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.bars {
|
||||
@@ -564,6 +556,9 @@ class HuiTemperatureForecastCardFeature
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
--accent-color: var(--feature-color);
|
||||
border-bottom-right-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -36,9 +36,14 @@ import { hasConfigChanged } from "../../common/has-changed";
|
||||
import type { LovelaceCard } from "../../types";
|
||||
import type { EnergyDistributionCardConfig } from "../types";
|
||||
import { formatNumber } from "../../../../common/number/format_number";
|
||||
import { round } from "../../../../common/number/round";
|
||||
|
||||
const CIRCLE_CIRCUMFERENCE = 238.76104;
|
||||
|
||||
// Flows are differences of sums; anything that rounds to 0 Wh is noise
|
||||
const hasFlow = (value: number | null): value is number =>
|
||||
round(value ?? 0, 3) > 0;
|
||||
|
||||
const periodIncludesNow = (data: EnergyData): boolean =>
|
||||
!data.end || data.end.getTime() >= Date.now();
|
||||
|
||||
@@ -843,8 +848,8 @@ class HuiEnergyDistrubutionCard
|
||||
? svg`<path
|
||||
id="battery-grid"
|
||||
class=${classMap({
|
||||
"battery-from-grid": Boolean(batteryFromGrid),
|
||||
"battery-to-grid": Boolean(batteryToGrid),
|
||||
"battery-from-grid": hasFlow(batteryFromGrid),
|
||||
"battery-to-grid": hasFlow(batteryToGrid),
|
||||
})}
|
||||
d="M45,100 v-15 c0,-35 -10,-30 -30,-30 h-20"
|
||||
vector-effect="non-scaling-stroke"
|
||||
@@ -875,14 +880,14 @@ class HuiEnergyDistrubutionCard
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
solarToGrid && this._animate
|
||||
hasFlow(solarToGrid) && this._animate
|
||||
? svg`<circle
|
||||
r="1"
|
||||
class="return"
|
||||
vector-effect="non-scaling-stroke"
|
||||
>
|
||||
<animateMotion
|
||||
dur="${6 - (solarToGrid / totalLines) * 6}s"
|
||||
dur="${6 - (solarToGrid / totalLines) * 5}s"
|
||||
repeatCount="indefinite"
|
||||
calcMode="linear"
|
||||
>
|
||||
@@ -892,7 +897,7 @@ class HuiEnergyDistrubutionCard
|
||||
: ""
|
||||
}
|
||||
${
|
||||
solarConsumption && this._animate
|
||||
hasFlow(solarConsumption) && this._animate
|
||||
? svg`<circle
|
||||
r="1"
|
||||
class="solar"
|
||||
@@ -909,7 +914,7 @@ class HuiEnergyDistrubutionCard
|
||||
: ""
|
||||
}
|
||||
${
|
||||
gridConsumption && this._animate
|
||||
hasFlow(gridConsumption) && this._animate
|
||||
? svg`<circle
|
||||
r="1"
|
||||
class="grid"
|
||||
@@ -926,7 +931,7 @@ class HuiEnergyDistrubutionCard
|
||||
: ""
|
||||
}
|
||||
${
|
||||
solarToBattery && this._animate
|
||||
hasFlow(solarToBattery) && this._animate
|
||||
? svg`<circle
|
||||
r="1"
|
||||
class="battery-solar"
|
||||
@@ -943,7 +948,7 @@ class HuiEnergyDistrubutionCard
|
||||
: ""
|
||||
}
|
||||
${
|
||||
batteryConsumption && this._animate
|
||||
hasFlow(batteryConsumption) && this._animate
|
||||
? svg`<circle
|
||||
r="1"
|
||||
class="battery-house"
|
||||
@@ -960,7 +965,7 @@ class HuiEnergyDistrubutionCard
|
||||
: ""
|
||||
}
|
||||
${
|
||||
batteryFromGrid && this._animate
|
||||
hasFlow(batteryFromGrid) && this._animate
|
||||
? svg`<circle
|
||||
r="1"
|
||||
class="battery-from-grid"
|
||||
@@ -978,7 +983,7 @@ class HuiEnergyDistrubutionCard
|
||||
: ""
|
||||
}
|
||||
${
|
||||
batteryToGrid && this._animate
|
||||
hasFlow(batteryToGrid) && this._animate
|
||||
? svg`<circle
|
||||
r="1"
|
||||
class="battery-to-grid"
|
||||
|
||||
@@ -348,6 +348,7 @@ class HuiEnergySankeyCard
|
||||
.hass=${this.hass}
|
||||
.data=${{ nodes, links }}
|
||||
.vertical=${vertical}
|
||||
.showValues=${this._config.show_values === true}
|
||||
.valueFormatter=${this._valueFormatter}
|
||||
@node-click=${this._handleNodeClick}
|
||||
></ha-sankey-chart>`
|
||||
|
||||
@@ -347,6 +347,7 @@ class HuiPowerSankeyCard
|
||||
.hass=${this.hass}
|
||||
.data=${{ nodes, links }}
|
||||
.vertical=${vertical}
|
||||
.showValues=${this._config.show_values === true}
|
||||
.valueFormatter=${this._valueFormatter}
|
||||
@node-click=${this._handleNodeClick}
|
||||
></ha-sankey-chart>`
|
||||
|
||||
@@ -9,7 +9,6 @@ import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-m
|
||||
import { migrateLayoutToGridOptions } from "../common/compute-card-grid-size";
|
||||
import { computeCardSize } from "../common/compute-card-size";
|
||||
import { getConfigEntityId } from "../common/get-config-entity-id";
|
||||
import { checkConditionsMet } from "../common/validate-condition";
|
||||
import { tryCreateCardElement } from "../create-element/create-card-element";
|
||||
import { createErrorCardElement } from "../create-element/create-element-base";
|
||||
import type { LovelaceCard, LovelaceGridOptions } from "../types";
|
||||
@@ -262,14 +261,7 @@ export class HuiCard extends ConditionalListenerMixin<LovelaceCardConfig>(
|
||||
return;
|
||||
}
|
||||
|
||||
const visible =
|
||||
conditionsMet ??
|
||||
(!this.config?.visibility ||
|
||||
checkConditionsMet(
|
||||
this.config.visibility,
|
||||
this.hass,
|
||||
this._conditionContext
|
||||
));
|
||||
const visible = conditionsMet ?? this._conditionsVisible();
|
||||
this._setElementVisibility(visible);
|
||||
}
|
||||
|
||||
|
||||
@@ -172,9 +172,11 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._config.heading
|
||||
? html`<p>${this._config.heading}</p>`
|
||||
: nothing
|
||||
this._config.heading && style === "subtitle"
|
||||
? html`<h3 class="heading">${this._config.heading}</h3>`
|
||||
: this._config.heading
|
||||
? html`<h2 class="heading">${this._config.heading}</h2>`
|
||||
: nothing
|
||||
}
|
||||
${actionable ? html`<ha-icon-next></ha-icon-next>` : nothing}
|
||||
</div>
|
||||
@@ -275,9 +277,12 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
|
||||
display: flex;
|
||||
flex: none;
|
||||
}
|
||||
.content p {
|
||||
.content .heading {
|
||||
margin: 0;
|
||||
font-style: normal;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user