mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-25 16:20:07 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0531888064 | ||
|
|
24e26b398e | ||
|
|
bf939babfd | ||
|
|
d41b21b9d6 | ||
|
|
7e495f38d2 | ||
|
|
5cff0cb4c9 | ||
|
|
dbe2e2c079 | ||
|
|
e22b770252 | ||
|
|
fd388d99bf | ||
|
|
f9bfc66d5b | ||
|
|
c4ab017aed | ||
|
|
e22ef3e1f3 | ||
|
|
7a3764790b | ||
|
|
c499c2e030 | ||
|
|
13ec52ddbe | ||
|
|
d288cfd30a | ||
|
|
643e8934b4 | ||
|
|
9f3ca39738 | ||
|
|
8cee5155e6 | ||
|
|
15341a0aad | ||
|
|
9f8a2253ac | ||
|
|
97df5a5bf0 |
@@ -22,7 +22,7 @@ When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body
|
||||
|
||||
- `yarn lint` passes when practical for the scope.
|
||||
- `yarn test` or focused relevant tests are green when practical for the scope.
|
||||
- Tests are added or updated for new data processing and utilities where applicable.
|
||||
- Each test added by the change protects real logic, not the look of a component.
|
||||
- User-facing text is localized and follows `ha-frontend-user-facing-text` guidance.
|
||||
- Components handle loading, error, unavailable, and missing-entity states.
|
||||
- Entity existence is checked before property access.
|
||||
|
||||
@@ -49,12 +49,13 @@ Do not pass `--help`, `--background`, or `--modern` to `script/build_frontend`;
|
||||
|
||||
Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so only one build or development server can run at a time.
|
||||
|
||||
## Unit And Utility Tests
|
||||
## When To Add Tests
|
||||
|
||||
- Add or update Vitest tests for data processing, utility code, and behavior that can be tested without a browser.
|
||||
- Mock WebSocket connections and API calls at boundaries.
|
||||
- Cover loading, error, unavailable, and missing-entity states where relevant.
|
||||
- Test accessibility-sensitive behavior when it can be asserted without brittle DOM internals.
|
||||
- Write tests for code that computes something: data processing, utility functions, config validation, and what happens when the user interacts with a component.
|
||||
- Do not write tests that check what a component looks like: its text, CSS classes, styles, or slots. Do not write tests that check the default value of an option.
|
||||
- A component that only takes data from contexts and helpers and puts it in a template does not need a test.
|
||||
- If you are not sure a test is useful, describe the test and what it would catch, and let the user decide.
|
||||
- Tests never talk to a real Home Assistant. Replace `callWS`, `callApi`, and the connection with fakes.
|
||||
|
||||
## Dev Servers
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Sync device class constants
|
||||
|
||||
# Mirrors the device class constants for Home Assistant Core's into the
|
||||
# build-time default in src/data/devce_classes.ts and opens a PR
|
||||
# when it drifts. Reads homeassistant/generated/device_classes.json from core.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 4 * * *" # Daily, 04:00 UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Sync
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Regenerate device class constants
|
||||
run: ./script/gen_device_classes
|
||||
|
||||
- name: Format
|
||||
run: yarn prettier --write src/data/sensor_numeric_device_classes.ts
|
||||
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
branch: chore/sync-numeric-device-classes
|
||||
commit-message: Update numeric sensor device classes
|
||||
title: Update numeric sensor device classes
|
||||
body: |
|
||||
Regenerated `SENSOR_NUMERIC_DEVICE_CLASSES` from Home Assistant Core's
|
||||
`SensorDeviceClass`.
|
||||
|
||||
Automated by `.github/workflows/sync-numeric-device-classes.yaml`.
|
||||
+6
-5
@@ -1,7 +1,8 @@
|
||||
name: Sync numeric device classes
|
||||
name: Sync sensor entity constants
|
||||
|
||||
# Mirrors Home Assistant Core's numeric `SensorDeviceClass` list into the
|
||||
# build-time default in src/data/sensor_numeric_device_classes.ts and opens a PR
|
||||
# Mirrors Home Assistant Core's numeric `SensorDeviceClass`, `SensorStateClass`,
|
||||
# units and related device and state classes arrays into the
|
||||
# build-time default in src/data/sensor_entity_constants.ts and opens a PR
|
||||
# when it drifts. Reads homeassistant/generated/sensor.json from core.
|
||||
|
||||
on:
|
||||
@@ -28,8 +29,8 @@ jobs:
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Regenerate numeric device classes
|
||||
run: ./script/gen_numeric_device_classes
|
||||
- name: Regenerate sensor entity constants
|
||||
run: ./script/gen_sensor_entity_constants
|
||||
|
||||
- name: Format
|
||||
run: yarn prettier --write src/data/sensor_numeric_device_classes.ts
|
||||
@@ -33,6 +33,7 @@ Never run `tsc` or `yarn lint:types` with file arguments. When `tsc` receives fi
|
||||
- Do not query or manipulate DOM manually when Lit decorators, component refs, or render state are appropriate.
|
||||
- Scope styles to components, use theme custom properties, and keep layouts mobile-first and RTL-safe.
|
||||
- All user-facing text must be localized through the translation system.
|
||||
- Do not write tests just because you changed some code. Write a test when there is real logic that could break without anyone noticing, and explain what the test protects.
|
||||
|
||||
## Project Skills
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
import gulp from "gulp";
|
||||
import paths from "../paths.cjs";
|
||||
|
||||
const SOURCE_URL =
|
||||
process.env.SENSOR_METADATA_URL ||
|
||||
"https://raw.githubusercontent.com/home-assistant/core/refs/heads/dev/homeassistant/generated/device_classes.json";
|
||||
|
||||
const TARGET = join(paths.root_dir, "src", "data", "device_classes.ts");
|
||||
|
||||
gulp.task("gen-device-classes", async () => {
|
||||
const response = await fetch(SOURCE_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const domainDeviceClasses = data ?? {};
|
||||
if (!Object.keys(domainDeviceClasses).length) {
|
||||
throw new Error(`No device classes found in ${SOURCE_URL}`);
|
||||
}
|
||||
|
||||
const content = `// This file is auto-generated from Home Assistant Core's
|
||||
// entity platform device classes. Do not edit by hand.
|
||||
// Regenerate with \`script/gen_device_classes\`.
|
||||
|
||||
export const DOMAIN_DEVICE_CLASSES: Record<string, string[]> = {
|
||||
${Object.entries(domainDeviceClasses)
|
||||
.map(
|
||||
([domain, deviceClasses]) =>
|
||||
` "${domain}": [${deviceClasses.map((deviceClass) => `"${deviceClass}"`).join(", ")}],`
|
||||
)
|
||||
.join("\n")}
|
||||
};
|
||||
`;
|
||||
|
||||
await writeFile(TARGET, content);
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
import gulp from "gulp";
|
||||
import paths from "../paths.cjs";
|
||||
|
||||
const SOURCE_URL =
|
||||
process.env.SENSOR_METADATA_URL ||
|
||||
"https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/generated/sensor.json";
|
||||
|
||||
const TARGET = join(
|
||||
paths.root_dir,
|
||||
"src",
|
||||
"data",
|
||||
"sensor_numeric_device_classes.ts"
|
||||
);
|
||||
|
||||
gulp.task("gen-numeric-device-classes", async () => {
|
||||
const response = await fetch(SOURCE_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const classes = [...(data.numeric_device_classes ?? [])].sort();
|
||||
if (!classes.length) {
|
||||
throw new Error(`No numeric_device_classes found in ${SOURCE_URL}`);
|
||||
}
|
||||
|
||||
const content = `// This file is auto-generated from Home Assistant Core's \`SensorDeviceClass\`
|
||||
// (all values minus \`NON_NUMERIC_DEVICE_CLASSES\`). Do not edit by hand.
|
||||
// Regenerate with \`script/gen_numeric_device_classes\`.
|
||||
|
||||
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
|
||||
${classes.map((deviceClass) => ` "${deviceClass}",`).join("\n")}
|
||||
];
|
||||
`;
|
||||
|
||||
await writeFile(TARGET, content);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
import gulp from "gulp";
|
||||
import paths from "../paths.cjs";
|
||||
|
||||
const SOURCE_URL =
|
||||
process.env.SENSOR_METADATA_URL ||
|
||||
"https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/generated/sensor.json";
|
||||
|
||||
const TARGET = join(
|
||||
paths.root_dir,
|
||||
"src",
|
||||
"data",
|
||||
"sensor_entity_constants.ts"
|
||||
);
|
||||
|
||||
gulp.task("gen-sensor-entity-constants", async () => {
|
||||
const response = await fetch(SOURCE_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const numericDeviceClasses = [...(data.numeric_device_classes ?? [])].sort();
|
||||
const deviceClassUnits = data.device_class_units ?? {};
|
||||
const convertibleClassUnits = data.convertible_units ?? {};
|
||||
const stateClasses = [...(data.state_classes ?? [])].sort();
|
||||
const stateClassUnits = data.state_class_units ?? {};
|
||||
if (
|
||||
!numericDeviceClasses.length ||
|
||||
!stateClasses.length ||
|
||||
!Object.keys(deviceClassUnits).length ||
|
||||
!Object.keys(stateClassUnits).length
|
||||
) {
|
||||
throw new Error(
|
||||
`No sensor device classes, state classes or units found in ${SOURCE_URL}`
|
||||
);
|
||||
}
|
||||
|
||||
const content = `// This file is auto-generated from Home Assistant Core's \`DEVICE_CLASS_UNITS\`
|
||||
// and \`STATE_CLASS_UNITS\`) and \`SensorDeviceClass\`
|
||||
// (all values minus \`NON_NUMERIC_DEVICE_CLASSES\`). Do not edit by hand.
|
||||
// Regenerate with \`script/gen_sensor_entity_constants\`.
|
||||
|
||||
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
|
||||
${numericDeviceClasses.map((deviceClass) => ` "${deviceClass}",`).join("\n")}
|
||||
];
|
||||
|
||||
export const SENSOR_DEVICE_CLASS_UNITS: Record<string, (string | null)[]> = {
|
||||
${Object.entries(deviceClassUnits)
|
||||
.map(
|
||||
([deviceClass, units]) =>
|
||||
` ${deviceClass}: [${units.map((u) => (u === null ? "null" : `"${u}"`)).join(", ")}],`
|
||||
)
|
||||
.join("\n")}
|
||||
};
|
||||
|
||||
export const SENSOR_DEVICE_CLASS_CONVERTIBLE_UNITS: Record<string, (string | null)[]> = {
|
||||
${Object.entries(convertibleClassUnits)
|
||||
.map(
|
||||
([deviceClass, units]) =>
|
||||
` ${deviceClass}: [${units.map((u) => (u === null ? "null" : `"${u}"`)).join(", ")}],`
|
||||
)
|
||||
.join("\n")}
|
||||
};
|
||||
|
||||
export const SENSOR_STATE_CLASSES: string[] = [
|
||||
${stateClasses.map((stateClass) => ` "${stateClass}",`).join("\n")}
|
||||
];
|
||||
|
||||
export const SENSOR_STATE_CLASS_UNITS: Record<string, string[]> = {
|
||||
${Object.entries(stateClassUnits)
|
||||
.map(
|
||||
([stateClass, units]) =>
|
||||
` ${stateClass}: [${units.map((u) => (u === null ? "null" : `"${u}"`)).join(", ")}],`
|
||||
)
|
||||
.join("\n")}
|
||||
};
|
||||
`;
|
||||
|
||||
await writeFile(TARGET, content);
|
||||
});
|
||||
@@ -9,8 +9,9 @@ import "./entry-html.js";
|
||||
import "./fetch-nightly-translations.js";
|
||||
import "./gallery.js";
|
||||
import "./gather-static.js";
|
||||
import "./gen-device-classes.js";
|
||||
import "./gen-icons-json.js";
|
||||
import "./gen-numeric-device-classes.js";
|
||||
import "./gen-sensor-entity-constants.js";
|
||||
import "./landing-page.js";
|
||||
import "./locale-data.js";
|
||||
import "./rspack.js";
|
||||
|
||||
+4
-4
@@ -152,9 +152,9 @@
|
||||
"@octokit/plugin-retry": "8.1.1",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.2",
|
||||
"@rsdoctor/rspack-plugin": "1.6.3",
|
||||
"@rspack/core": "2.1.10",
|
||||
"@rspack/dev-server": "2.2.0",
|
||||
"@rspack/dev-server": "2.2.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
@@ -165,7 +165,7 @@
|
||||
"@types/leaflet-draw": "1.0.13",
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
"@types/lodash.merge": "4.6.9",
|
||||
"@types/luxon": "3.7.4",
|
||||
"@types/luxon": "3.7.5",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
@@ -176,7 +176,7 @@
|
||||
"browserslist": "4.28.8",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.8.1",
|
||||
"eslint": "10.9.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
|
||||
@@ -8,4 +8,4 @@ set -eu -o pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
./node_modules/.bin/gulp gen-numeric-device-classes
|
||||
./node_modules/.bin/gulp gen-device-classes
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Safe bash settings
|
||||
# -e Exit on command fail
|
||||
# -u Exit on unset variable
|
||||
# -o pipefail Exit if piped command has error code
|
||||
set -eu -o pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
./node_modules/.bin/gulp gen-sensor-entity-constants
|
||||
@@ -12,24 +12,13 @@ import { getEntityContext } from "./context/get_entity_context";
|
||||
const DEFAULT_SEPARATOR = " ";
|
||||
|
||||
export const DEFAULT_ENTITY_NAME = [
|
||||
{ type: "parent_device" },
|
||||
{ type: "device" },
|
||||
{ type: "entity" },
|
||||
] satisfies EntityNameItem[];
|
||||
|
||||
export const ENTITY_NAME_TYPES = [
|
||||
"entity",
|
||||
"device",
|
||||
"parent_device",
|
||||
"area",
|
||||
"floor",
|
||||
] as const;
|
||||
|
||||
export type EntityNameType = (typeof ENTITY_NAME_TYPES)[number];
|
||||
|
||||
export type EntityNameItem =
|
||||
| {
|
||||
type: EntityNameType;
|
||||
type: "entity" | "device" | "area" | "floor";
|
||||
}
|
||||
| {
|
||||
type: "text";
|
||||
@@ -102,7 +91,7 @@ export const computeEntityNameList = (
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"]
|
||||
): (string | undefined)[] => {
|
||||
const { device, parentDevice, area, floor } = getEntityContext(
|
||||
const { device, area, floor } = getEntityContext(
|
||||
stateObj,
|
||||
entities,
|
||||
devices,
|
||||
@@ -116,8 +105,6 @@ export const computeEntityNameList = (
|
||||
return computeEntityName(stateObj, entities, devices);
|
||||
case "device":
|
||||
return device ? computeDeviceName(device) : undefined;
|
||||
case "parent_device":
|
||||
return parentDevice ? computeDeviceName(parentDevice) : undefined;
|
||||
case "area":
|
||||
return area ? computeAreaName(area) : undefined;
|
||||
case "floor":
|
||||
@@ -149,20 +136,14 @@ export const computeEntityPickerDisplay = (
|
||||
>,
|
||||
stateObj: HassEntity
|
||||
): EntityPickerDisplay => {
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(
|
||||
hass.language,
|
||||
@@ -171,7 +152,7 @@ export const computeEntityPickerDisplay = (
|
||||
|
||||
const primary = entityName || deviceName || stateObj.entity_id;
|
||||
const secondary =
|
||||
[areaName, parentDeviceName, entityName ? deviceName : undefined]
|
||||
[areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ") || undefined;
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import { getDeviceAreaId } from "./get_device_context";
|
||||
interface EntityContext {
|
||||
entity: EntityRegistryDisplayEntry | null;
|
||||
device: DeviceRegistryEntry | null;
|
||||
parentDevice: DeviceRegistryEntry | null;
|
||||
area: AreaRegistryEntry | null;
|
||||
floor: FloorRegistryEntry | null;
|
||||
}
|
||||
@@ -32,7 +31,6 @@ export const getEntityContext = (
|
||||
return {
|
||||
entity: null,
|
||||
device: null,
|
||||
parentDevice: null,
|
||||
area: null,
|
||||
floor: null,
|
||||
};
|
||||
@@ -67,9 +65,6 @@ export const getEntityEntryContext = (
|
||||
const entity = entities[entry.entity_id];
|
||||
const deviceId = entry?.device_id;
|
||||
const device = deviceId ? devices[deviceId] : undefined;
|
||||
const parentDevice = device?.parent_device_id
|
||||
? devices[device.parent_device_id]
|
||||
: undefined;
|
||||
const areaId =
|
||||
entry?.area_id || (device ? getDeviceAreaId(device, devices) : undefined);
|
||||
const area = areaId ? areas[areaId] : undefined;
|
||||
@@ -79,7 +74,6 @@ export const getEntityEntryContext = (
|
||||
return {
|
||||
entity: entity,
|
||||
device: device || null,
|
||||
parentDevice: parentDevice || null,
|
||||
area: area || null,
|
||||
floor: floor || null,
|
||||
};
|
||||
|
||||
@@ -304,6 +304,9 @@ export const DOMAIN_OPTIONS_ATTRIBUTES: Record<
|
||||
swing_mode: "swing_modes",
|
||||
swing_horizontal_mode: "swing_horizontal_modes",
|
||||
},
|
||||
cover: {
|
||||
speed: "supported_speeds",
|
||||
},
|
||||
event: {
|
||||
event_type: "event_types",
|
||||
},
|
||||
|
||||
@@ -31,6 +31,8 @@ export type FormatEntityAttributeNameFunc = (
|
||||
attribute: string
|
||||
) => string;
|
||||
|
||||
export type EntityNameType = "entity" | "device" | "area" | "floor";
|
||||
|
||||
export type FormatEntityNameFunc = (
|
||||
stateObj: HassEntity,
|
||||
name: EntityNameItem | EntityNameItem[],
|
||||
|
||||
@@ -55,17 +55,20 @@ export const timeCachePromiseFunc = async <T, H = HomeAssistant>(
|
||||
}
|
||||
|
||||
const resultPromise = func(hass, ...args);
|
||||
anyHass[cacheKey] = resultPromise;
|
||||
const cachePromise = resultPromise.then((result) => ({
|
||||
result,
|
||||
cacheKey: generateCacheKey?.(hass, result),
|
||||
}));
|
||||
anyHass[cacheKey] = cachePromise;
|
||||
|
||||
resultPromise.then(
|
||||
cachePromise.then(
|
||||
// When successful, set timer to clear cache
|
||||
(result) => {
|
||||
anyHass[cacheKey] = {
|
||||
result,
|
||||
cacheKey: generateCacheKey?.(hass, result),
|
||||
};
|
||||
anyHass[cacheKey] = result;
|
||||
setTimeout(() => {
|
||||
anyHass[cacheKey] = undefined;
|
||||
if (anyHass[cacheKey] === result) {
|
||||
anyHass[cacheKey] = undefined;
|
||||
}
|
||||
}, cacheTime);
|
||||
},
|
||||
// On failure, clear cache right away
|
||||
|
||||
@@ -27,26 +27,57 @@ const SONIFICATION_LANGUAGES = new Set(["de", "en", "es", "fr", "hmn", "it"]);
|
||||
// lead nowhere.
|
||||
const MIN_NAVIGABLE_POINTS = 2;
|
||||
|
||||
// Mirrors the extension's own reading of a point: it takes `value` as [x, y] and
|
||||
// drops anything whose y is not a real number. That rejects gap-only series, and
|
||||
// also value-first pairs like the energy device charts' [amount, "sensor.foo"].
|
||||
// Counts no further than `limit` so this stays cheap on charts with many points.
|
||||
const itemValues = (raw: unknown): unknown[] | null => {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw;
|
||||
}
|
||||
if (raw && typeof raw === "object") {
|
||||
const { value } = raw as { value?: unknown };
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// ECharts spells empty values and numbers as strings too, and neither names a
|
||||
// category.
|
||||
const NON_CATEGORY_STRINGS = new Set(["-", "NaN", "null", "undefined"]);
|
||||
|
||||
const isCategoryKey = (value: unknown): boolean =>
|
||||
typeof value === "string" &&
|
||||
!NON_CATEGORY_STRINGS.has(value) &&
|
||||
Number.isNaN(Number(value));
|
||||
|
||||
// A chart with the value axis on x, like the energy device charts, encodes its
|
||||
// items value-first: [amount, "sensor.foo"]. The extension only reads a series
|
||||
// that way when every item has that shape, so mirror the same gate.
|
||||
const isValueFirstSeries = (data: readonly unknown[]): boolean =>
|
||||
data.length > 0 &&
|
||||
data.every((raw) => {
|
||||
const values = itemValues(raw);
|
||||
return (
|
||||
!!values && typeof values[0] === "number" && isCategoryKey(values[1])
|
||||
);
|
||||
});
|
||||
|
||||
// Mirrors the extension's own reading of a point: it takes `value` as [x, y]
|
||||
// (or [y, category] in a value-first series) and drops anything whose y is not
|
||||
// a real number, which rejects gap-only series. Counts no further than `limit`
|
||||
// so this stays cheap on charts with many points.
|
||||
const countNumericPoints = (data: unknown, limit: number): number => {
|
||||
if (!Array.isArray(data)) {
|
||||
return 0;
|
||||
}
|
||||
const valueFirst = isValueFirstSeries(data);
|
||||
let found = 0;
|
||||
for (const raw of data) {
|
||||
let y: unknown = raw;
|
||||
if (Array.isArray(raw)) {
|
||||
y = raw.length > 1 ? raw[1] : raw[0];
|
||||
const values = itemValues(raw);
|
||||
if (values) {
|
||||
y = valueFirst ? values[0] : values.length > 1 ? values[1] : values[0];
|
||||
} else if (raw && typeof raw === "object") {
|
||||
const { value } = raw as { value?: unknown };
|
||||
y = Array.isArray(value)
|
||||
? value.length > 1
|
||||
? value[1]
|
||||
: value[0]
|
||||
: value;
|
||||
y = (raw as { value?: unknown }).value;
|
||||
}
|
||||
if (typeof y === "number" && !Number.isNaN(y)) {
|
||||
found += 1;
|
||||
@@ -95,6 +126,10 @@ interface SonifyChartOptions {
|
||||
localize: LocalizeFunc;
|
||||
locale: FrontendLocaleData;
|
||||
config: HassConfig;
|
||||
// Maps a category key or item name to what should be announced for it, so
|
||||
// cards that key their data on ids (like the energy device charts) can have
|
||||
// the display names read out instead. Returning undefined keeps the original.
|
||||
formatLabel?: (label: string) => string | undefined;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
|
||||
@@ -163,6 +198,47 @@ const appendSonificationStyles = () => {
|
||||
document.head.append(style);
|
||||
};
|
||||
|
||||
// Rebuilds the labels the extension would announce — the category axis's data,
|
||||
// or the item names on pies, which ignore whatever vestigial axes the chart
|
||||
// options carry — with each one run through the card's formatter. Returns
|
||||
// undefined when there is nothing to reword, so the extension's own labels
|
||||
// stay untouched.
|
||||
const buildValueLabels = (
|
||||
categoryAxis: { type?: string; data?: unknown } | undefined,
|
||||
firstSeries: { type?: string; data?: unknown } | undefined,
|
||||
formatLabel?: (label: string) => string | undefined
|
||||
): string[] | undefined => {
|
||||
if (!formatLabel) {
|
||||
return undefined;
|
||||
}
|
||||
const axisData =
|
||||
categoryAxis?.type === "category" &&
|
||||
Array.isArray(categoryAxis.data) &&
|
||||
categoryAxis.data.length
|
||||
? categoryAxis.data
|
||||
: undefined;
|
||||
const labels = axisData
|
||||
? axisData.map((entry) =>
|
||||
entry && typeof entry === "object"
|
||||
? String((entry as { value?: unknown }).value ?? "")
|
||||
: String(entry ?? "")
|
||||
)
|
||||
: firstSeries?.type === "pie" && Array.isArray(firstSeries.data)
|
||||
? firstSeries.data.map((raw) => {
|
||||
const name = (raw as { name?: unknown } | null)?.name;
|
||||
if (typeof name === "string") {
|
||||
return name;
|
||||
}
|
||||
const values = itemValues(raw);
|
||||
return values && isCategoryKey(values[1]) ? String(values[1]) : "";
|
||||
})
|
||||
: undefined;
|
||||
if (!labels?.length || labels.every((label) => !label)) {
|
||||
return undefined;
|
||||
}
|
||||
return labels.map((label) => formatLabel(label) ?? label);
|
||||
};
|
||||
|
||||
export const sonifyChart = async (
|
||||
chart: EChartsType,
|
||||
options: SonifyChartOptions
|
||||
@@ -179,8 +255,8 @@ export const sonifyChart = async (
|
||||
if (!chartOptions) {
|
||||
return null;
|
||||
}
|
||||
const xAxis = ensureArray(chartOptions.xAxis)[0] as XAXisOption | undefined;
|
||||
const yAxis = ensureArray(chartOptions.yAxis)[0] as YAXisOption | undefined;
|
||||
const xAxis = ensureArray(chartOptions.xAxis)?.[0] as XAXisOption | undefined;
|
||||
const yAxis = ensureArray(chartOptions.yAxis)?.[0] as YAXisOption | undefined;
|
||||
|
||||
// Chart2Music throws while validating a group with no points, which is what
|
||||
// placeholder, legend-hidden and all-null series turn into, so only offer it
|
||||
@@ -196,16 +272,25 @@ export const sonifyChart = async (
|
||||
const seriesIndex = readable.map((s) => allSeries.indexOf(s));
|
||||
|
||||
// Chart2Music always reads out an axis label, and the extension picks the wrong
|
||||
// axis to name when there is no category axis, so label both explicitly.
|
||||
// axis to name when there is no category axis, so label both explicitly. On a
|
||||
// horizontal chart the announced x is the category from the y axis and the
|
||||
// announced y is the value from the x axis, so the sources swap.
|
||||
const isTimeAxis = xAxis?.type === "time";
|
||||
const isHorizontal = xAxis?.type === "value" && yAxis?.type === "category";
|
||||
const valueLabels = buildValueLabels(
|
||||
isHorizontal ? yAxis : xAxis,
|
||||
readable[0],
|
||||
options.formatLabel
|
||||
);
|
||||
const x = {
|
||||
label:
|
||||
xAxis?.name ||
|
||||
(isHorizontal ? yAxis?.name : xAxis?.name) ||
|
||||
localize(
|
||||
isTimeAxis
|
||||
? "ui.components.history_charts.time"
|
||||
: "ui.components.history_charts.category"
|
||||
),
|
||||
...(valueLabels ? { valueLabels } : {}),
|
||||
// Time series carry raw timestamps, which would otherwise be announced as
|
||||
// epoch milliseconds.
|
||||
format: isTimeAxis
|
||||
@@ -213,7 +298,9 @@ export const sonifyChart = async (
|
||||
: undefined,
|
||||
};
|
||||
const y = {
|
||||
label: yAxis?.name || localize("ui.components.history_charts.value"),
|
||||
label:
|
||||
(isHorizontal ? xAxis?.name : yAxis?.name) ||
|
||||
localize("ui.components.history_charts.value"),
|
||||
};
|
||||
|
||||
let connection: ReturnType<typeof connect>;
|
||||
|
||||
@@ -117,6 +117,11 @@ export class HaChartBase extends LitElement {
|
||||
|
||||
@property({ type: String }) public height?: string;
|
||||
|
||||
// Lets cards that key their data on ids have display names announced
|
||||
// instead when the chart is navigated with Chart2Music.
|
||||
@property({ attribute: false })
|
||||
public sonificationLabelFormatter?: (label: string) => string | undefined;
|
||||
|
||||
@property({ attribute: "expand-legend", type: Boolean })
|
||||
public expandLegend?: boolean;
|
||||
|
||||
@@ -583,6 +588,7 @@ export class HaChartBase extends LitElement {
|
||||
localize: this.hass.localize,
|
||||
locale: this.hass.locale,
|
||||
config: this.hass.config,
|
||||
formatLabel: this.sonificationLabelFormatter,
|
||||
onError: () => {
|
||||
// Charts the extension cannot describe stay silent rather than
|
||||
// dropping an error on someone who only pressed Tab.
|
||||
|
||||
@@ -7,12 +7,9 @@ import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import {
|
||||
ENTITY_NAME_TYPES,
|
||||
type EntityNameItem,
|
||||
type EntityNameType,
|
||||
} from "../../common/entity/compute_entity_name_display";
|
||||
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
|
||||
import { getEntityContext } from "../../common/entity/context/get_entity_context";
|
||||
import type { EntityNameType } from "../../common/translations/entity-state";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../chips/ha-assist-chip";
|
||||
@@ -38,7 +35,9 @@ const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
const KNOWN_TYPES = new Set<string>(ENTITY_NAME_TYPES);
|
||||
const KNOWN_TYPES = new Set(["entity", "device", "area", "floor"]);
|
||||
|
||||
const UNIQUE_TYPES = new Set(["entity", "device", "area", "floor"]);
|
||||
|
||||
const formatOptionValue = (item: EntityNameItem) => {
|
||||
if (item.type === "text" && item.text) {
|
||||
@@ -388,7 +387,6 @@ export class HaEntityNamePicker extends LitElement {
|
||||
);
|
||||
|
||||
if (context.device) options.add("device");
|
||||
if (context.parentDevice) options.add("parent_device");
|
||||
if (context.area) options.add("area");
|
||||
if (context.floor) options.add("floor");
|
||||
return options;
|
||||
@@ -401,7 +399,9 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
const types = this._validTypes(entityId);
|
||||
|
||||
const items = ENTITY_NAME_TYPES.map<PickerComboBoxItem>((name) => {
|
||||
const items = (
|
||||
["entity", "device", "area", "floor"] as const
|
||||
).map<PickerComboBoxItem>((name) => {
|
||||
const stateObj = this.hass.states[entityId];
|
||||
const isValid = types.has(name);
|
||||
const primary = this.hass.localize(
|
||||
@@ -464,7 +464,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
const excludedValues = new Set(
|
||||
this._items
|
||||
.filter((item) => KNOWN_TYPES.has(item.type))
|
||||
.filter((item) => UNIQUE_TYPES.has(item.type))
|
||||
.map((item) => formatOptionValue(item))
|
||||
);
|
||||
|
||||
|
||||
@@ -178,17 +178,6 @@ export class HaStateContentPicker extends LitElement {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (context.parentDevice) {
|
||||
contextItems.push({
|
||||
id: "parent_device_name",
|
||||
primary: this.hass.localize(
|
||||
"ui.components.state-content-picker.parent_device_name"
|
||||
),
|
||||
sorting_label: this.hass.localize(
|
||||
"ui.components.state-content-picker.parent_device_name"
|
||||
),
|
||||
});
|
||||
}
|
||||
if (context.area) {
|
||||
contextItems.push({
|
||||
id: "area_name",
|
||||
|
||||
@@ -52,7 +52,6 @@ const SEARCH_KEYS = [
|
||||
{ name: "search_labels.entityName", weight: 10 },
|
||||
{ name: "search_labels.friendlyName", weight: 9 },
|
||||
{ name: "search_labels.deviceName", weight: 8 },
|
||||
{ name: "search_labels.parentDeviceName", weight: 6 },
|
||||
{ name: "search_labels.areaName", weight: 6 },
|
||||
{ name: "search_labels.domainName", weight: 4 },
|
||||
{ name: "statisticId", weight: 3 },
|
||||
@@ -293,27 +292,17 @@ export class HaStatisticPicker extends LitElement {
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || id;
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
@@ -329,7 +318,6 @@ export class HaStatisticPicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
},
|
||||
@@ -407,20 +395,14 @@ export class HaStatisticPicker extends LitElement {
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
|
||||
if (stateObj) {
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(
|
||||
this.hass.language,
|
||||
@@ -428,11 +410,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || statisticId;
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
@@ -449,7 +427,6 @@ export class HaStatisticPicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
statisticId,
|
||||
|
||||
@@ -197,27 +197,17 @@ export class HaAreaControlsPicker extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass!.entities,
|
||||
this.hass!.devices,
|
||||
this.hass!.areas,
|
||||
this.hass!.floors
|
||||
);
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass!.entities,
|
||||
this.hass!.devices,
|
||||
this.hass!.areas,
|
||||
this.hass!.floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
|
||||
@@ -761,9 +761,6 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
const deviceName = context.device
|
||||
? computeDeviceName(context.device)
|
||||
: undefined;
|
||||
const parentDeviceName = context.parentDevice
|
||||
? computeDeviceName(context.parentDevice)
|
||||
: undefined;
|
||||
const areaName = context.area ? computeAreaName(context.area) : undefined;
|
||||
const floorName = context.floor
|
||||
? computeFloorName(context.floor)
|
||||
@@ -791,15 +788,6 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
});
|
||||
}
|
||||
|
||||
if (parentDeviceName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize(
|
||||
"ui.components.device-picker.parent_device"
|
||||
),
|
||||
value: parentDeviceName,
|
||||
});
|
||||
}
|
||||
|
||||
if (deviceName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize("ui.components.device-picker.device"),
|
||||
|
||||
@@ -101,7 +101,7 @@ export class HaControlSelect extends LitElement {
|
||||
|
||||
private _handleOptionClick(ev: MouseEvent) {
|
||||
if (this.disabled) return;
|
||||
const value = (ev.target as any).value;
|
||||
const value = (ev.currentTarget as any).value;
|
||||
this.value = value;
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
@@ -109,7 +109,7 @@ export class HaControlSelect extends LitElement {
|
||||
private _handleOptionMouseDown(ev: MouseEvent) {
|
||||
if (this.disabled) return;
|
||||
ev.preventDefault();
|
||||
const value = (ev.target as any).value;
|
||||
const value = (ev.currentTarget as any).value;
|
||||
this._activeIndex = this.options?.findIndex(
|
||||
(option) => option.value === value
|
||||
);
|
||||
@@ -121,7 +121,7 @@ export class HaControlSelect extends LitElement {
|
||||
|
||||
private _handleOptionFocus(ev: FocusEvent) {
|
||||
if (this.disabled) return;
|
||||
const value = (ev.target as any).value;
|
||||
const value = (ev.currentTarget as any).value;
|
||||
this._activeIndex = this.options?.findIndex(
|
||||
(option) => option.value === value
|
||||
);
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent, type HASSDomEvent } from "../common/dom/fire_event";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { internationalizationContext, statesContext } from "../data/context";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import "./ha-check-list-item";
|
||||
import "./ha-domain-icon";
|
||||
import "./ha-expansion-panel";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-list";
|
||||
import "./input/ha-input-search";
|
||||
import type { HaInputSearch } from "./input/ha-input-search";
|
||||
|
||||
interface DeviceClassItem {
|
||||
deviceClass: string;
|
||||
domain: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@customElement("ha-filter-device-classes")
|
||||
export class HaFilterDeviceClasses extends LitElement {
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
@state()
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
@state()
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@property({ attribute: false }) public value?: string[];
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
${this._localize("ui.components.filter-device-classes.caption")}
|
||||
${
|
||||
this.value?.length
|
||||
? html`<div class="badge">${this.value?.length}</div>
|
||||
<ha-icon-button
|
||||
.path=${mdiFilterVariantRemove}
|
||||
@click=${this._clearFilter}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
class="ha-scrollbar"
|
||||
@selected=${this._handleItemSelected}
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._deviceClasses(
|
||||
this._states,
|
||||
this._localize,
|
||||
this._i18n.locale.language,
|
||||
this._filter
|
||||
),
|
||||
(item) => item.deviceClass,
|
||||
(item) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${item.deviceClass}
|
||||
.selected=${(this.value || []).includes(item.deviceClass)}
|
||||
graphic="icon"
|
||||
>
|
||||
<ha-domain-icon
|
||||
slot="graphic"
|
||||
.domain=${item.domain}
|
||||
.deviceClass=${item.deviceClass}
|
||||
.state=${item.domain === "binary_sensor" ? "on" : undefined}
|
||||
></ha-domain-icon>
|
||||
${item.name}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list> `
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
private _deviceClasses = memoizeOne(
|
||||
(
|
||||
states: ContextType<typeof statesContext>,
|
||||
localize: LocalizeFunc,
|
||||
language: string | undefined,
|
||||
filter: string | undefined
|
||||
): DeviceClassItem[] =>
|
||||
this._deviceClassItems(this._deviceClassDomains(states), localize)
|
||||
.filter(
|
||||
(item) =>
|
||||
!filter ||
|
||||
item.deviceClass.toLowerCase().includes(filter) ||
|
||||
item.name.toLowerCase().includes(filter)
|
||||
)
|
||||
.sort((a, b) => stringCompare(a.name, b.name, language))
|
||||
);
|
||||
|
||||
private _deviceClassDomains = memoizeOne(
|
||||
(states: ContextType<typeof statesContext>): Map<string, string[]> => {
|
||||
const domains = new Map<string, string[]>();
|
||||
Object.values(states).forEach((stateObj) => {
|
||||
const deviceClass = stateObj.attributes.device_class;
|
||||
if (!deviceClass) {
|
||||
return;
|
||||
}
|
||||
const domain = computeStateDomain(stateObj);
|
||||
const known = domains.get(deviceClass);
|
||||
if (!known) {
|
||||
domains.set(deviceClass, [domain]);
|
||||
} else if (!known.includes(domain)) {
|
||||
known.push(domain);
|
||||
}
|
||||
});
|
||||
return domains;
|
||||
}
|
||||
);
|
||||
|
||||
private _deviceClassItems = memoizeOne(
|
||||
(
|
||||
deviceClassDomains: Map<string, string[]>,
|
||||
localize: LocalizeFunc
|
||||
): DeviceClassItem[] =>
|
||||
[...deviceClassDomains].map(([deviceClass, domains]) => {
|
||||
for (const domain of domains) {
|
||||
const name = localize(
|
||||
`component.${domain}.entity_component.${deviceClass}.name`
|
||||
);
|
||||
if (name) {
|
||||
return { deviceClass, domain, name };
|
||||
}
|
||||
}
|
||||
return { deviceClass, domain: domains[0], name: deviceClass };
|
||||
}),
|
||||
([domainsA, localizeA], [domainsB, localizeB]) =>
|
||||
localizeA === localizeB &&
|
||||
domainsA.size === domainsB.size &&
|
||||
[...domainsA].every(
|
||||
([deviceClass, domains]) =>
|
||||
domainsB.get(deviceClass)?.join() === domains.join()
|
||||
)
|
||||
);
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev: HASSDomEvent<{ expanded: boolean }>) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev: HASSDomEvent<{ expanded: boolean }>) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _handleItemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
|
||||
const deviceClasses = this._deviceClasses(
|
||||
this._states,
|
||||
this._localize,
|
||||
this._i18n.locale.language,
|
||||
this._filter
|
||||
);
|
||||
|
||||
const visible = new Set(deviceClasses.map((item) => item.deviceClass));
|
||||
const preserved = (this.value || []).filter((d) => !visible.has(d));
|
||||
const selected = [...ev.detail.index]
|
||||
.map((i) => deviceClasses[i]?.deviceClass)
|
||||
.filter((d): d is string => !!d);
|
||||
|
||||
this.value = [...preserved, ...selected];
|
||||
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: this.value.length ? this.value : undefined,
|
||||
items: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private _clearFilter(ev: Event) {
|
||||
ev.preventDefault();
|
||||
this.value = undefined;
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: undefined,
|
||||
items: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: InputEvent) {
|
||||
const target = ev.target as HaInputSearch;
|
||||
this._filter = (target.value ?? "").toLowerCase();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: initial;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-filter-device-classes": HaFilterDeviceClasses;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import {
|
||||
mdiChevronDown,
|
||||
mdiChevronUp,
|
||||
mdiFilterVariantRemove,
|
||||
mdiShape,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { computeRTL } from "../common/util/compute_rtl";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { internationalizationContext, statesContext } from "../data/context";
|
||||
import {
|
||||
computeDeviceClassName,
|
||||
NO_DEVICE_CLASS,
|
||||
} from "../data/entity/device_class";
|
||||
import {
|
||||
entityTypeKey,
|
||||
parseEntityType,
|
||||
usedEntityTypes,
|
||||
} from "../data/entity/entity_type";
|
||||
import { domainToName } from "../data/integration";
|
||||
import "./ha-domain-icon";
|
||||
import "./ha-expansion-panel";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-svg-icon";
|
||||
import "./ha-tree-indicator";
|
||||
import "./input/ha-input-search";
|
||||
import type { HaInputSearch } from "./input/ha-input-search";
|
||||
import "./item/ha-list-item-option";
|
||||
import type { HaListItemOption } from "./item/ha-list-item-option";
|
||||
import "./list/ha-list-selectable";
|
||||
import type { HaListSelectable } from "./list/ha-list-selectable";
|
||||
|
||||
// Core picks this one from the battery level, so it has no usable default.
|
||||
const FIXED_TYPE_ICONS: Record<string, string> = {
|
||||
"sensor/battery": "mdi:battery",
|
||||
};
|
||||
|
||||
interface TypeRow {
|
||||
key: string;
|
||||
domain: string;
|
||||
deviceClass?: string;
|
||||
name: string;
|
||||
deviceClasses?: string[];
|
||||
expanded?: boolean;
|
||||
last?: boolean;
|
||||
}
|
||||
|
||||
@customElement("ha-filter-entity-types")
|
||||
export class HaFilterEntityTypes extends LitElement {
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
@state()
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
@state()
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@property({ attribute: false }) public value?: string[];
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@state() private _expandedDomains = new Set<string>();
|
||||
|
||||
@query("ha-list-selectable") private _list?: HaListSelectable;
|
||||
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
private _badgeTypes?: Map<string, string[]>;
|
||||
|
||||
protected render() {
|
||||
const count = this.value?.length
|
||||
? this._count(this.value, this._badgeTypes!)
|
||||
: 0;
|
||||
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
${this._localize("ui.components.filter-entity-types.caption")}
|
||||
${
|
||||
count
|
||||
? html`<div class="badge">${count}</div>
|
||||
<ha-icon-button
|
||||
.path=${mdiFilterVariantRemove}
|
||||
@click=${this._clearFilter}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
${this._panel.showContent ? this._renderContent() : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderContent() {
|
||||
const rows = this._rows(
|
||||
this._states,
|
||||
this._localize,
|
||||
this._i18n.locale.language,
|
||||
this._filter,
|
||||
this._expandedDomains
|
||||
);
|
||||
const rtl = computeRTL(
|
||||
this._i18n.language,
|
||||
this._i18n.translationMetadata.translations
|
||||
);
|
||||
|
||||
return html`<div class="content" ${ref(this._content)}>
|
||||
<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list-selectable
|
||||
multi
|
||||
controlled
|
||||
aria-label=${this._localize("ui.components.filter-entity-types.caption")}
|
||||
@ha-list-item-selected=${this._handleItemToggled}
|
||||
@ha-list-item-deselected=${this._handleItemToggled}
|
||||
>
|
||||
${repeat(
|
||||
rows,
|
||||
(row) => row.key,
|
||||
(row) => this._renderRow(row, rtl)
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderRow(row: TypeRow, rtl: boolean) {
|
||||
const selected = this._isSelected(row);
|
||||
const expandable = !!row.deviceClasses?.length;
|
||||
|
||||
return html`
|
||||
<ha-list-item-option
|
||||
appearance="checkbox"
|
||||
selection-position="end"
|
||||
class=${classMap({ child: !!row.deviceClass, rtl })}
|
||||
.value=${row.key}
|
||||
.selected=${selected}
|
||||
.indeterminate=${!selected && this._isPartiallySelected(row)}
|
||||
>
|
||||
${
|
||||
row.deviceClass
|
||||
? html`<ha-tree-indicator
|
||||
slot="start"
|
||||
.end=${!!row.last}
|
||||
></ha-tree-indicator>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
row.deviceClass === NO_DEVICE_CLASS
|
||||
? html`<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>`
|
||||
: html`<ha-domain-icon
|
||||
slot="start"
|
||||
.icon=${FIXED_TYPE_ICONS[row.key]}
|
||||
.domain=${row.domain}
|
||||
.deviceClass=${row.deviceClass}
|
||||
.state=${row.domain === "binary_sensor" ? "on" : undefined}
|
||||
?brand-fallback=${!row.deviceClass}
|
||||
></ha-domain-icon>`
|
||||
}
|
||||
<span slot="headline">${row.name}</span>
|
||||
${
|
||||
expandable
|
||||
? html`<ha-icon-button
|
||||
slot="end"
|
||||
data-domain=${row.domain}
|
||||
.path=${row.expanded ? mdiChevronUp : mdiChevronDown}
|
||||
.label=${this._localize(
|
||||
row.expanded
|
||||
? "ui.components.filter-entity-types.collapse"
|
||||
: "ui.components.filter-entity-types.expand"
|
||||
)}
|
||||
@click=${this._toggleDomain}
|
||||
@keydown=${this._handleChevronKeydown}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
</ha-list-item-option>
|
||||
`;
|
||||
}
|
||||
|
||||
private _types = memoizeOne(usedEntityTypes);
|
||||
|
||||
// A selected domain counts for the classes it stands for, so that collapsing
|
||||
// the last one does not drop the count to one.
|
||||
private _count = memoizeOne(
|
||||
(value: string[], types: Map<string, string[]>): number =>
|
||||
value.reduce((count, key) => {
|
||||
const { domain, deviceClass } = parseEntityType(key);
|
||||
return (
|
||||
count +
|
||||
(deviceClass ? 1 : Math.max(types.get(domain)?.length ?? 0, 1))
|
||||
);
|
||||
}, 0)
|
||||
);
|
||||
|
||||
private _rows = memoizeOne(
|
||||
(
|
||||
states: ContextType<typeof statesContext>,
|
||||
localize: LocalizeFunc,
|
||||
language: string | undefined,
|
||||
filter: string | undefined,
|
||||
expandedDomains: Set<string>
|
||||
): TypeRow[] => {
|
||||
const types = this._types(states);
|
||||
|
||||
const domains = [...types.keys()]
|
||||
.map((domain) => ({ domain, name: domainToName(localize, domain) }))
|
||||
.sort((a, b) => stringCompare(a.name, b.name, language));
|
||||
|
||||
const rows: TypeRow[] = [];
|
||||
|
||||
for (const { domain, name } of domains) {
|
||||
const deviceClasses = types
|
||||
.get(domain)!
|
||||
.map((deviceClass) => ({
|
||||
deviceClass,
|
||||
name: this._deviceClassName(localize, domain, deviceClass),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.deviceClass === NO_DEVICE_CLASS) {
|
||||
return 1;
|
||||
}
|
||||
if (b.deviceClass === NO_DEVICE_CLASS) {
|
||||
return -1;
|
||||
}
|
||||
return stringCompare(a.name, b.name, language);
|
||||
});
|
||||
|
||||
const matchingClasses = deviceClasses.filter((entry) =>
|
||||
this._matches(filter, entry.deviceClass, entry.name)
|
||||
);
|
||||
const domainMatches = this._matches(filter, domain, name);
|
||||
|
||||
if (!domainMatches && !matchingClasses.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only a search that matched nothing but device classes unfolds them.
|
||||
const revealed = !!filter && !domainMatches;
|
||||
const expanded = revealed || expandedDomains.has(domain);
|
||||
|
||||
rows.push({
|
||||
key: domain,
|
||||
domain,
|
||||
name,
|
||||
deviceClasses: deviceClasses.map((entry) => entry.deviceClass),
|
||||
expanded,
|
||||
});
|
||||
|
||||
if (!deviceClasses.length || !expanded) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const children = revealed ? matchingClasses : deviceClasses;
|
||||
|
||||
children.forEach((entry, index) => {
|
||||
rows.push({
|
||||
key: entityTypeKey(domain, entry.deviceClass),
|
||||
domain,
|
||||
deviceClass: entry.deviceClass,
|
||||
name: entry.name,
|
||||
last: index === children.length - 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
);
|
||||
|
||||
private _deviceClassName(
|
||||
localize: LocalizeFunc,
|
||||
domain: string,
|
||||
deviceClass: string
|
||||
): string {
|
||||
return deviceClass === NO_DEVICE_CLASS
|
||||
? localize("ui.components.filter-entity-types.no_device_class")
|
||||
: computeDeviceClassName(localize, domain, deviceClass);
|
||||
}
|
||||
|
||||
private _matches(
|
||||
filter: string | undefined,
|
||||
slug: string,
|
||||
name: string
|
||||
): boolean {
|
||||
return (
|
||||
!filter ||
|
||||
slug.toLowerCase().includes(filter) ||
|
||||
name.toLowerCase().includes(filter)
|
||||
);
|
||||
}
|
||||
|
||||
private _isSelected(row: TypeRow): boolean {
|
||||
const value = this.value;
|
||||
if (!value?.length) {
|
||||
return false;
|
||||
}
|
||||
return value.includes(row.domain) || value.includes(row.key);
|
||||
}
|
||||
|
||||
private _isPartiallySelected(row: TypeRow): boolean {
|
||||
if (row.deviceClass || !this.value?.length) {
|
||||
return false;
|
||||
}
|
||||
return this.value.some(
|
||||
(key) => parseEntityType(key).domain === row.domain && key !== row.domain
|
||||
);
|
||||
}
|
||||
|
||||
public willUpdate(changed: PropertyValues<this>) {
|
||||
super.willUpdate(changed);
|
||||
|
||||
// While closed, the badge reuses the classes it last saw rather than
|
||||
// rescanning every entity on each state change.
|
||||
if (this._panel.showContent || !this._badgeTypes) {
|
||||
this._badgeTypes = this._types(this._states);
|
||||
}
|
||||
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
this._expandedDomains = new Set(
|
||||
(this.value ?? [])
|
||||
.map((key) => parseEntityType(key))
|
||||
.filter((type) => type.deviceClass)
|
||||
.map((type) => type.domain)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
|
||||
// The list activates the focused row on Enter and Space, which would select
|
||||
// the domain instead of expanding it.
|
||||
private _handleChevronKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
private _toggleDomain(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
const { domain } = (ev.currentTarget as HTMLElement).dataset;
|
||||
if (!domain) {
|
||||
return;
|
||||
}
|
||||
const expandedDomains = new Set(this._expandedDomains);
|
||||
if (!expandedDomains.delete(domain)) {
|
||||
expandedDomains.add(domain);
|
||||
}
|
||||
this._expandedDomains = expandedDomains;
|
||||
}
|
||||
|
||||
private _handleItemToggled(ev: CustomEvent<number>) {
|
||||
// The list indexes its items by registration order, which a search reorders,
|
||||
// so read the key off the clicked option instead.
|
||||
const option = this._list?.items[ev.detail] as HaListItemOption | undefined;
|
||||
const key = option?.value;
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const { domain, deviceClass } = parseEntityType(key);
|
||||
|
||||
const value = new Set(this.value ?? []);
|
||||
const siblings = (this._types(this._states).get(domain) ?? []).map(
|
||||
(entry) => entityTypeKey(domain, entry)
|
||||
);
|
||||
|
||||
// Drops the classes the domain no longer exposes too, so that a stale key
|
||||
// can never sit next to the domain that covers it.
|
||||
const selectDomain = () => {
|
||||
value.forEach((selected) => {
|
||||
if (parseEntityType(selected).domain === domain) {
|
||||
value.delete(selected);
|
||||
}
|
||||
});
|
||||
value.add(domain);
|
||||
};
|
||||
|
||||
if (!deviceClass) {
|
||||
if (!value.delete(domain)) {
|
||||
selectDomain();
|
||||
}
|
||||
} else if (value.delete(domain)) {
|
||||
siblings.forEach((sibling) => {
|
||||
if (sibling !== key) {
|
||||
value.add(sibling);
|
||||
}
|
||||
});
|
||||
} else if (!value.delete(key)) {
|
||||
value.add(key);
|
||||
if (siblings.length && siblings.every((sibling) => value.has(sibling))) {
|
||||
selectDomain();
|
||||
}
|
||||
}
|
||||
|
||||
this.value = [...value];
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: this.value.length ? this.value : undefined,
|
||||
items: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private _clearFilter(ev: Event) {
|
||||
ev.preventDefault();
|
||||
this.value = undefined;
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: undefined,
|
||||
items: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: InputEvent) {
|
||||
const target = ev.target as HaInputSearch;
|
||||
this._filter = (target.value ?? "").toLowerCase();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
filterPanelStyles,
|
||||
css`
|
||||
/* The list scrolls through its own container, not through the host. */
|
||||
ha-list-selectable {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
ha-list-selectable::part(base) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: initial;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
/* Keeps a row that carries the chevron as tall as one that does not. */
|
||||
ha-list-item-option {
|
||||
--ha-row-item-padding-block: var(--ha-space-2);
|
||||
}
|
||||
ha-list-item-option ha-icon-button {
|
||||
--ha-icon-button-size: 32px;
|
||||
}
|
||||
.child::part(base) {
|
||||
padding-inline-start: 48px;
|
||||
}
|
||||
ha-tree-indicator {
|
||||
width: 56px;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
}
|
||||
.rtl ha-tree-indicator {
|
||||
right: 0px;
|
||||
left: initial;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-filter-entity-types": HaFilterEntityTypes;
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,15 @@ import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import type { DataTableFiltersValue } from "../data/data_table_filters";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
|
||||
import { entityTypeFilterFunc } from "../data/entity/entity_type";
|
||||
import type { EntitySources } from "../data/entity/entity_sources";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-filter-device-classes";
|
||||
import "./ha-filter-domains";
|
||||
import "./ha-filter-entity-types";
|
||||
import "./ha-filter-integrations";
|
||||
import "./ha-target-picker";
|
||||
|
||||
@@ -19,8 +19,8 @@ import "./ha-target-picker";
|
||||
* confused with `EntitySources`, which maps an entity to its integration.
|
||||
*/
|
||||
export interface SourceFilters {
|
||||
domains?: string[];
|
||||
deviceClasses?: string[];
|
||||
/** Domains (`sensor`) and domains narrowed to a device class (`sensor/power`). */
|
||||
types?: string[];
|
||||
integrations?: string[];
|
||||
}
|
||||
|
||||
@@ -44,38 +44,30 @@ export const countSourceFilters = (filters: SourceFilters): number =>
|
||||
Object.values(filters).filter((value) => value?.length).length;
|
||||
|
||||
/**
|
||||
* Narrows entity IDs down by the selected filters: an entity is kept when it
|
||||
* matches every filter that has a selection.
|
||||
* Matches an entity against the selected filters: it is kept when it matches
|
||||
* every filter that has a selection. Undefined when nothing is selected.
|
||||
*/
|
||||
export const applySourceFilters = (
|
||||
entityIds: string[],
|
||||
export const sourceFilterFunc = (
|
||||
filters: SourceFilters,
|
||||
states: HomeAssistant["states"],
|
||||
entities: HomeAssistant["entities"],
|
||||
entitySources?: EntitySources
|
||||
): string[] => {
|
||||
const domains = filters.domains?.length ? filters.domains : undefined;
|
||||
const deviceClasses = filters.deviceClasses?.length
|
||||
? filters.deviceClasses
|
||||
): ((entityId: string) => boolean) | undefined => {
|
||||
const matchesType = filters.types?.length
|
||||
? entityTypeFilterFunc(filters.types, states)
|
||||
: undefined;
|
||||
const integrations = filters.integrations?.length
|
||||
? filters.integrations
|
||||
: undefined;
|
||||
|
||||
if (!domains && !deviceClasses && !integrations) {
|
||||
return entityIds;
|
||||
if (!matchesType && !integrations) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return entityIds.filter((entityId) => {
|
||||
if (domains && !domains.includes(computeDomain(entityId))) {
|
||||
return (entityId: string) => {
|
||||
if (matchesType && !matchesType(entityId)) {
|
||||
return false;
|
||||
}
|
||||
if (deviceClasses) {
|
||||
const deviceClass = states[entityId]?.attributes.device_class;
|
||||
if (!deviceClass || !deviceClasses.includes(deviceClass)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (integrations) {
|
||||
const integration =
|
||||
entities[entityId]?.platform ?? entitySources?.[entityId]?.domain;
|
||||
@@ -84,13 +76,24 @@ export const applySourceFilters = (
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
/** Narrows entity IDs down by the selected filters. */
|
||||
export const applySourceFilters = (
|
||||
entityIds: string[],
|
||||
filters: SourceFilters,
|
||||
states: HomeAssistant["states"],
|
||||
entities: HomeAssistant["entities"],
|
||||
entitySources?: EntitySources
|
||||
): string[] => {
|
||||
const matches = sourceFilterFunc(filters, states, entities, entitySources);
|
||||
return matches ? entityIds.filter(matches) : entityIds;
|
||||
};
|
||||
|
||||
/**
|
||||
* Picker for what a page shows: the targets to include, narrowed down by
|
||||
* domain, device class and integration. Meant to be placed in an
|
||||
* `ha-filter-pane`.
|
||||
* entity type and integration. Meant to be placed in an `ha-filter-pane`.
|
||||
*
|
||||
* The pages resolve every entity of a target, secondary ones included, so the
|
||||
* target picker counts them too.
|
||||
@@ -106,6 +109,8 @@ export class HaSourcesPicker extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
|
||||
@property({ attribute: false }) public entitySources?: EntitySources;
|
||||
|
||||
/** Explains what the page shows while no target is picked. */
|
||||
@property() public description?: string;
|
||||
|
||||
@@ -129,6 +134,12 @@ export class HaSourcesPicker extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.entityFilter=${this.entityFilter}
|
||||
.activeFilter=${this._activeFilter(
|
||||
this.filters,
|
||||
this.hass.states,
|
||||
this.hass.entities,
|
||||
this.entitySources
|
||||
)}
|
||||
.primaryEntitiesOnly=${false}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetsChanged}
|
||||
@@ -136,18 +147,12 @@ export class HaSourcesPicker extends LitElement {
|
||||
<div
|
||||
class=${classMap({ filters: true, expanded: !!this._expandedFilter })}
|
||||
>
|
||||
<ha-filter-domains
|
||||
.value=${this.filters.domains}
|
||||
.expanded=${this._expandedFilter === "domains"}
|
||||
@data-table-filter-changed=${this._domainsChanged}
|
||||
@expanded-changed=${this._domainsExpanded}
|
||||
></ha-filter-domains>
|
||||
<ha-filter-device-classes
|
||||
.value=${this.filters.deviceClasses}
|
||||
.expanded=${this._expandedFilter === "deviceClasses"}
|
||||
@data-table-filter-changed=${this._deviceClassesChanged}
|
||||
@expanded-changed=${this._deviceClassesExpanded}
|
||||
></ha-filter-device-classes>
|
||||
<ha-filter-entity-types
|
||||
.value=${this.filters.types}
|
||||
.expanded=${this._expandedFilter === "types"}
|
||||
@data-table-filter-changed=${this._typesChanged}
|
||||
@expanded-changed=${this._typesExpanded}
|
||||
></ha-filter-entity-types>
|
||||
<ha-filter-integrations
|
||||
.value=${this.filters.integrations}
|
||||
.expanded=${this._expandedFilter === "integrations"}
|
||||
@@ -158,6 +163,8 @@ export class HaSourcesPicker extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _activeFilter = memoizeOne(sourceFilterFunc);
|
||||
|
||||
protected firstUpdated() {
|
||||
// The filter panels label themselves with keys from the config panel.
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
@@ -168,12 +175,8 @@ export class HaSourcesPicker extends LitElement {
|
||||
fireEvent(this, "value-changed", { value: ev.detail.value || {} });
|
||||
}
|
||||
|
||||
private _domainsChanged(ev: CustomEvent) {
|
||||
this._filterChanged("domains", ev);
|
||||
}
|
||||
|
||||
private _deviceClassesChanged(ev: CustomEvent) {
|
||||
this._filterChanged("deviceClasses", ev);
|
||||
private _typesChanged(ev: CustomEvent) {
|
||||
this._filterChanged("types", ev);
|
||||
}
|
||||
|
||||
private _integrationsChanged(ev: CustomEvent) {
|
||||
@@ -191,12 +194,8 @@ export class HaSourcesPicker extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _domainsExpanded(ev: CustomEvent) {
|
||||
this._filterExpanded("domains", ev);
|
||||
}
|
||||
|
||||
private _deviceClassesExpanded(ev: CustomEvent) {
|
||||
this._filterExpanded("deviceClasses", ev);
|
||||
private _typesExpanded(ev: CustomEvent) {
|
||||
this._filterExpanded("types", ev);
|
||||
}
|
||||
|
||||
private _integrationsExpanded(ev: CustomEvent) {
|
||||
|
||||
@@ -108,6 +108,13 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false })
|
||||
public entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
|
||||
/**
|
||||
* Entities that pass the filters the page currently has on. Narrows the
|
||||
* counts, unlike `entityFilter`, which says what can be picked at all.
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
public activeFilter?: (entityId: string) => boolean;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@state() private _selectedSection?: TargetTypeFloorless;
|
||||
@@ -286,6 +293,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
.items=${{ entity: entityIds }}
|
||||
.deviceFilter=${this.deviceFilter}
|
||||
.entityFilter=${this.entityFilter}
|
||||
.activeFilter=${this.activeFilter}
|
||||
.includeDomains=${this.includeDomains}
|
||||
.includeDeviceClasses=${this.includeDeviceClasses}
|
||||
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
|
||||
@@ -306,6 +314,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
.items=${{ device: deviceIds }}
|
||||
.deviceFilter=${this.deviceFilter}
|
||||
.entityFilter=${this.entityFilter}
|
||||
.activeFilter=${this.activeFilter}
|
||||
.includeDomains=${this.includeDomains}
|
||||
.includeDeviceClasses=${this.includeDeviceClasses}
|
||||
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
|
||||
@@ -329,6 +338,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
}}
|
||||
.deviceFilter=${this.deviceFilter}
|
||||
.entityFilter=${this.entityFilter}
|
||||
.activeFilter=${this.activeFilter}
|
||||
.includeDomains=${this.includeDomains}
|
||||
.includeDeviceClasses=${this.includeDeviceClasses}
|
||||
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
|
||||
@@ -348,6 +358,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
.items=${{ label: labelIds }}
|
||||
.deviceFilter=${this.deviceFilter}
|
||||
.entityFilter=${this.entityFilter}
|
||||
.activeFilter=${this.activeFilter}
|
||||
.includeDomains=${this.includeDomains}
|
||||
.includeDeviceClasses=${this.includeDeviceClasses}
|
||||
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
|
||||
|
||||
@@ -470,15 +470,19 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
? MediaClassBrowserSettings[currentItem.children_media_class]
|
||||
: MediaClassBrowserSettings.directory;
|
||||
|
||||
const canPickCurrent =
|
||||
currentItem?.can_play ||
|
||||
(currentItem && this.accept?.includes("directory"));
|
||||
|
||||
return html`
|
||||
${
|
||||
currentItem.can_play || showSearch
|
||||
canPickCurrent || showSearch
|
||||
? html`
|
||||
<div
|
||||
class="header ${classMap({
|
||||
"no-img": !currentItem.thumbnail,
|
||||
"no-dialog": !this.dialog,
|
||||
"search-only": !currentItem.can_play,
|
||||
"search-only": !canPickCurrent,
|
||||
})}"
|
||||
@transitionend=${this._setHeaderHeight}
|
||||
>
|
||||
@@ -491,7 +495,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
currentItem.can_play
|
||||
canPickCurrent
|
||||
? html`<div class="header-content">
|
||||
${
|
||||
currentItem.thumbnail
|
||||
@@ -503,7 +507,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
></ha-media-browser-thumbnail>
|
||||
${
|
||||
this.narrow &&
|
||||
currentItem?.can_play &&
|
||||
canPickCurrent &&
|
||||
(!this.accept ||
|
||||
canPlayChildren.has(
|
||||
currentItem.media_content_id
|
||||
@@ -546,7 +550,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
}
|
||||
</div>
|
||||
${
|
||||
currentItem.can_play &&
|
||||
canPickCurrent &&
|
||||
(!currentItem.thumbnail || !this.narrow)
|
||||
? html`
|
||||
<ha-button
|
||||
|
||||
@@ -129,31 +129,21 @@ export class HaMediaPlayerPicker extends LitElement {
|
||||
.filter(this._filterPlayerEntities)
|
||||
.map<MediaPlayerComboBoxItem>((stateObj) => {
|
||||
const friendlyName = computeStateName(stateObj);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this._entities,
|
||||
this._devices,
|
||||
this._areas,
|
||||
this._floors
|
||||
);
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this._entities,
|
||||
this._devices,
|
||||
this._areas,
|
||||
this._floors
|
||||
);
|
||||
const entityId = stateObj.entity_id;
|
||||
const domainName = domainToName(
|
||||
this._i18n.localize,
|
||||
computeDomain(entityId)
|
||||
);
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
@@ -167,7 +157,6 @@ export class HaMediaPlayerPicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
domainName: domainName || null,
|
||||
friendlyName: friendlyName || null,
|
||||
|
||||
@@ -62,27 +62,17 @@ class HaMediaPlayerToggle extends LitElement {
|
||||
isRTL: boolean,
|
||||
stateObj: HassEntity
|
||||
) => {
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
|
||||
@@ -118,6 +118,16 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
);
|
||||
};
|
||||
|
||||
private _combinedFilter = memoizeOne(
|
||||
(
|
||||
entityFilter: HaEntityPickerEntityFilterFunc | undefined,
|
||||
activeFilter: (entityId: string) => boolean
|
||||
): HaEntityPickerEntityFilterFunc =>
|
||||
(stateObj) =>
|
||||
(!entityFilter || entityFilter(stateObj)) &&
|
||||
activeFilter(stateObj.entity_id)
|
||||
);
|
||||
|
||||
private _selectorTarget() {
|
||||
return this._params?.selector?.target || null;
|
||||
}
|
||||
@@ -127,6 +137,8 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const { activeFilter } = this._params;
|
||||
|
||||
let deviceFilter: HaDevicePickerDeviceFilterFunc | undefined;
|
||||
let entityFilter: HaEntityPickerEntityFilterFunc | undefined;
|
||||
let includeDomains: string[] | undefined;
|
||||
@@ -145,6 +157,10 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
primaryEntitiesOnly = this._params.primaryEntitiesOnly;
|
||||
}
|
||||
|
||||
if (activeFilter) {
|
||||
entityFilter = this._combinedFilter(entityFilter, activeFilter);
|
||||
}
|
||||
|
||||
const waitingForSources =
|
||||
this._params.selector &&
|
||||
this._hasIntegration(this._params.selector) &&
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface TargetDetailsDialogParams {
|
||||
selector?: TargetSelector;
|
||||
deviceFilter?: HaDevicePickerDeviceFilterFunc;
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
activeFilter?: (entityId: string) => boolean;
|
||||
includeDomains?: string[];
|
||||
includeDeviceClasses?: string[];
|
||||
primaryEntitiesOnly?: boolean;
|
||||
|
||||
@@ -34,6 +34,9 @@ export class HaTargetPickerItemGroup extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
|
||||
@property({ attribute: false })
|
||||
public activeFilter?: (entityId: string) => boolean;
|
||||
|
||||
/**
|
||||
* Show only targets with entities from specific domains.
|
||||
* @type {Array}
|
||||
@@ -88,6 +91,7 @@ export class HaTargetPickerItemGroup extends LitElement {
|
||||
.itemId=${item}
|
||||
.deviceFilter=${this.deviceFilter}
|
||||
.entityFilter=${this.entityFilter}
|
||||
.activeFilter=${this.activeFilter}
|
||||
.includeDomains=${this.includeDomains}
|
||||
.includeDeviceClasses=${this.includeDeviceClasses}
|
||||
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
|
||||
|
||||
@@ -92,6 +92,13 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
|
||||
/**
|
||||
* Entities that pass the filters the page currently has on. Narrows the
|
||||
* count, and the target details, but not what the target resolves to.
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
public activeFilter?: (entityId: string) => boolean;
|
||||
|
||||
/**
|
||||
* Show only targets with entities from specific domains.
|
||||
* @type {Array}
|
||||
@@ -189,7 +196,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
}
|
||||
</div>
|
||||
|
||||
<div slot="headline">${(canMigrate && replacement?.name) || name}</div>
|
||||
<span slot="headline">${(canMigrate && replacement?.name) || name}</span>
|
||||
${
|
||||
notFound || (context && !this.hideContext)
|
||||
? html`<span slot="supporting-text"
|
||||
@@ -222,12 +229,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
${
|
||||
this.expand || !entries.referenced_entities.length
|
||||
? html`<span class="main">
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.entities_count",
|
||||
{
|
||||
count: entries.referenced_entities.length,
|
||||
}
|
||||
)}
|
||||
${this._entitiesLabel(entries)}
|
||||
</span>`
|
||||
: html`<ha-button
|
||||
appearance="filled"
|
||||
@@ -235,12 +237,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
size="xs"
|
||||
@click=${this._openDetails}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.entities_count",
|
||||
{
|
||||
count: entries.referenced_entities.length,
|
||||
}
|
||||
)}
|
||||
${this._entitiesLabel(entries)}
|
||||
</ha-button>`
|
||||
}
|
||||
</div>
|
||||
@@ -334,6 +331,28 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _entityCounts(entries: ExtractFromTargetResultReferenced) {
|
||||
const total = entries.referenced_entities.length;
|
||||
return {
|
||||
total,
|
||||
count: this.activeFilter
|
||||
? entries.referenced_entities.filter(this.activeFilter).length
|
||||
: total,
|
||||
};
|
||||
}
|
||||
|
||||
private _entitiesLabel(entries: ExtractFromTargetResultReferenced): string {
|
||||
const { count, total } = this._entityCounts(entries);
|
||||
return this.activeFilter
|
||||
? this.hass.localize(
|
||||
"ui.components.target-picker.entities_count_filtered",
|
||||
{ count, total }
|
||||
)
|
||||
: this.hass.localize("ui.components.target-picker.entities_count", {
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
private _renderEntries() {
|
||||
const entries = this.parentEntries || this._entries;
|
||||
|
||||
@@ -683,7 +702,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
const entityName = stateObject
|
||||
? computeEntityName(stateObject, this.hass.entities, this.hass.devices)
|
||||
: item;
|
||||
const { area, device, parentDevice } = stateObject
|
||||
const { area, device } = stateObject
|
||||
? getEntityContext(
|
||||
stateObject,
|
||||
this.hass.entities,
|
||||
@@ -691,17 +710,10 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
)
|
||||
: { area: undefined, device: undefined, parentDevice: undefined };
|
||||
: { area: undefined, device: undefined };
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const parentDeviceName = parentDevice
|
||||
? computeDeviceName(parentDevice)
|
||||
: undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
const context = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
const context = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(
|
||||
computeRTL(
|
||||
@@ -823,6 +835,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
itemId: this.itemId,
|
||||
deviceFilter: this.deviceFilter,
|
||||
entityFilter: this.entityFilter,
|
||||
activeFilter: this.activeFilter,
|
||||
includeDomains: this.includeDomains,
|
||||
includeDeviceClasses: this.includeDeviceClasses,
|
||||
primaryEntitiesOnly: this.primaryEntitiesOnly,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// This file is auto-generated from Home Assistant Core's
|
||||
// entity platform device classes. Do not edit by hand.
|
||||
// Regenerate with `script/gen_device_classes`.
|
||||
|
||||
export const DOMAIN_DEVICE_CLASSES: Record<string, string[]> = {
|
||||
binary_sensor: [
|
||||
"battery",
|
||||
"battery_charging",
|
||||
"carbon_monoxide",
|
||||
"cold",
|
||||
"connectivity",
|
||||
"door",
|
||||
"garage_door",
|
||||
"gas",
|
||||
"heat",
|
||||
"light",
|
||||
"lock",
|
||||
"moisture",
|
||||
"motion",
|
||||
"moving",
|
||||
"occupancy",
|
||||
"opening",
|
||||
"plug",
|
||||
"power",
|
||||
"presence",
|
||||
"problem",
|
||||
"running",
|
||||
"safety",
|
||||
"smoke",
|
||||
"sound",
|
||||
"tamper",
|
||||
"update",
|
||||
"vibration",
|
||||
"window",
|
||||
],
|
||||
button: ["identify", "restart", "update"],
|
||||
cover: [
|
||||
"awning",
|
||||
"blind",
|
||||
"curtain",
|
||||
"damper",
|
||||
"door",
|
||||
"garage",
|
||||
"gate",
|
||||
"shade",
|
||||
"shutter",
|
||||
"window",
|
||||
],
|
||||
event: ["button", "doorbell", "motion"],
|
||||
humidifier: ["dehumidifier", "humidifier"],
|
||||
infrared: ["emitter", "receiver"],
|
||||
media_player: ["projector", "receiver", "speaker", "tv"],
|
||||
number: [
|
||||
"absolute_humidity",
|
||||
"apparent_power",
|
||||
"aqi",
|
||||
"area",
|
||||
"atmospheric_pressure",
|
||||
"battery",
|
||||
"blood_glucose_concentration",
|
||||
"carbon_dioxide",
|
||||
"carbon_monoxide",
|
||||
"conductivity",
|
||||
"current",
|
||||
"data_rate",
|
||||
"data_size",
|
||||
"distance",
|
||||
"duration",
|
||||
"energy",
|
||||
"energy_distance",
|
||||
"energy_storage",
|
||||
"frequency",
|
||||
"gas",
|
||||
"humidity",
|
||||
"illuminance",
|
||||
"irradiance",
|
||||
"moisture",
|
||||
"monetary",
|
||||
"nitrogen_dioxide",
|
||||
"nitrogen_monoxide",
|
||||
"nitrous_oxide",
|
||||
"ozone",
|
||||
"ph",
|
||||
"pm1",
|
||||
"pm10",
|
||||
"pm25",
|
||||
"pm4",
|
||||
"power",
|
||||
"power_factor",
|
||||
"precipitation",
|
||||
"precipitation_intensity",
|
||||
"pressure",
|
||||
"radon",
|
||||
"reactive_energy",
|
||||
"reactive_power",
|
||||
"signal_strength",
|
||||
"sound_pressure",
|
||||
"speed",
|
||||
"sulphur_dioxide",
|
||||
"temperature",
|
||||
"temperature_delta",
|
||||
"volatile_organic_compounds",
|
||||
"volatile_organic_compounds_parts",
|
||||
"voltage",
|
||||
"volume",
|
||||
"volume_flow_rate",
|
||||
"volume_storage",
|
||||
"water",
|
||||
"weight",
|
||||
"wind_direction",
|
||||
"wind_speed",
|
||||
],
|
||||
sensor: [
|
||||
"absolute_humidity",
|
||||
"apparent_power",
|
||||
"aqi",
|
||||
"area",
|
||||
"atmospheric_pressure",
|
||||
"battery",
|
||||
"blood_glucose_concentration",
|
||||
"carbon_dioxide",
|
||||
"carbon_monoxide",
|
||||
"conductivity",
|
||||
"current",
|
||||
"data_rate",
|
||||
"data_size",
|
||||
"date",
|
||||
"distance",
|
||||
"duration",
|
||||
"energy",
|
||||
"energy_distance",
|
||||
"energy_storage",
|
||||
"enum",
|
||||
"frequency",
|
||||
"gas",
|
||||
"humidity",
|
||||
"illuminance",
|
||||
"irradiance",
|
||||
"moisture",
|
||||
"monetary",
|
||||
"nitrogen_dioxide",
|
||||
"nitrogen_monoxide",
|
||||
"nitrous_oxide",
|
||||
"ozone",
|
||||
"ph",
|
||||
"pm1",
|
||||
"pm10",
|
||||
"pm25",
|
||||
"pm4",
|
||||
"power",
|
||||
"power_factor",
|
||||
"precipitation",
|
||||
"precipitation_intensity",
|
||||
"pressure",
|
||||
"radon",
|
||||
"reactive_energy",
|
||||
"reactive_power",
|
||||
"signal_strength",
|
||||
"sound_pressure",
|
||||
"speed",
|
||||
"sulphur_dioxide",
|
||||
"temperature",
|
||||
"temperature_delta",
|
||||
"timestamp",
|
||||
"uptime",
|
||||
"volatile_organic_compounds",
|
||||
"volatile_organic_compounds_parts",
|
||||
"voltage",
|
||||
"volume",
|
||||
"volume_flow_rate",
|
||||
"volume_storage",
|
||||
"water",
|
||||
"weight",
|
||||
"wind_direction",
|
||||
"wind_speed",
|
||||
],
|
||||
switch: ["outlet", "switch"],
|
||||
update: ["firmware"],
|
||||
valve: ["gas", "water"],
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "../sensor_entity_constants";
|
||||
|
||||
export const NO_DEVICE_CLASS = "none";
|
||||
|
||||
// Mirrors core's `<Domain>DeviceClass` enums, until the backend exposes them.
|
||||
export const DOMAIN_DEVICE_CLASSES: Record<string, readonly string[]> = {
|
||||
binary_sensor: [
|
||||
"battery",
|
||||
"battery_charging",
|
||||
"carbon_monoxide",
|
||||
"cold",
|
||||
"connectivity",
|
||||
"door",
|
||||
"garage_door",
|
||||
"gas",
|
||||
"heat",
|
||||
"light",
|
||||
"lock",
|
||||
"moisture",
|
||||
"motion",
|
||||
"moving",
|
||||
"occupancy",
|
||||
"opening",
|
||||
"plug",
|
||||
"power",
|
||||
"presence",
|
||||
"problem",
|
||||
"running",
|
||||
"safety",
|
||||
"smoke",
|
||||
"sound",
|
||||
"tamper",
|
||||
"update",
|
||||
"vibration",
|
||||
"window",
|
||||
],
|
||||
button: ["identify", "restart", "update"],
|
||||
cover: [
|
||||
"awning",
|
||||
"blind",
|
||||
"curtain",
|
||||
"damper",
|
||||
"door",
|
||||
"garage",
|
||||
"gate",
|
||||
"shade",
|
||||
"shutter",
|
||||
"window",
|
||||
],
|
||||
event: ["button", "doorbell", "motion"],
|
||||
humidifier: ["dehumidifier", "humidifier"],
|
||||
image_processing: ["alpr", "face", "ocr"],
|
||||
infrared: ["emitter", "receiver"],
|
||||
media_player: ["projector", "receiver", "speaker", "tv"],
|
||||
number: SENSOR_NUMERIC_DEVICE_CLASSES,
|
||||
sensor: [
|
||||
...SENSOR_NUMERIC_DEVICE_CLASSES,
|
||||
"date",
|
||||
"enum",
|
||||
"timestamp",
|
||||
"uptime",
|
||||
],
|
||||
switch: ["outlet", "switch"],
|
||||
update: ["firmware"],
|
||||
valve: ["gas", "water"],
|
||||
};
|
||||
|
||||
export const computeDeviceClassName = (
|
||||
localize: LocalizeFunc,
|
||||
domain: string,
|
||||
deviceClass: string
|
||||
): string =>
|
||||
localize(`component.${domain}.entity_component.${deviceClass}.name`) ||
|
||||
deviceClass;
|
||||
@@ -31,10 +31,6 @@ export const entityComboBoxKeys: FuseWeightedKey[] = [
|
||||
name: "search_labels.deviceName",
|
||||
weight: 7,
|
||||
},
|
||||
{
|
||||
name: "search_labels.parentDeviceName",
|
||||
weight: 6,
|
||||
},
|
||||
{
|
||||
name: "search_labels.areaName",
|
||||
weight: 6,
|
||||
@@ -133,20 +129,14 @@ export const getEntities = (
|
||||
const stateObj = hass.states[entityId];
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const domain = computeDomain(entityId);
|
||||
let domainName = domainNames.get(domain);
|
||||
@@ -156,11 +146,7 @@ export const getEntities = (
|
||||
}
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
@@ -173,7 +159,6 @@ export const getEntities = (
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
domainName: domainName || null,
|
||||
friendlyName: friendlyName || null,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { DOMAIN_DEVICE_CLASSES, NO_DEVICE_CLASS } from "./device_class";
|
||||
|
||||
const SEPARATOR = "/";
|
||||
|
||||
export interface EntityType {
|
||||
domain: string;
|
||||
deviceClass?: string;
|
||||
}
|
||||
|
||||
export const entityTypeKey = (domain: string, deviceClass?: string): string =>
|
||||
deviceClass ? `${domain}${SEPARATOR}${deviceClass}` : domain;
|
||||
|
||||
export const parseEntityType = (key: string): EntityType => {
|
||||
const index = key.indexOf(SEPARATOR);
|
||||
return index === -1
|
||||
? { domain: key }
|
||||
: {
|
||||
domain: key.slice(0, index),
|
||||
deviceClass: key.slice(index + SEPARATOR.length),
|
||||
};
|
||||
};
|
||||
|
||||
export const entityTypesNeedStates = (types?: string[]): boolean =>
|
||||
!!types?.some((key) => key.includes(SEPARATOR));
|
||||
|
||||
// A domain worth no split maps to an empty list rather than to its lone bucket.
|
||||
export const usedEntityTypes = (
|
||||
states: HomeAssistant["states"]
|
||||
): Map<string, string[]> => {
|
||||
const byDomain = new Map<string, Set<string>>();
|
||||
|
||||
for (const stateObj of Object.values(states)) {
|
||||
const domain = computeDomain(stateObj.entity_id);
|
||||
let classes = byDomain.get(domain);
|
||||
if (!classes) {
|
||||
classes = new Set();
|
||||
byDomain.set(domain, classes);
|
||||
}
|
||||
if (domain in DOMAIN_DEVICE_CLASSES) {
|
||||
classes.add(stateObj.attributes.device_class || NO_DEVICE_CLASS);
|
||||
}
|
||||
}
|
||||
|
||||
return new Map(
|
||||
[...byDomain].map(([domain, classes]) => [
|
||||
domain,
|
||||
classes.size > 1 ? [...classes] : [],
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
// Relies on a domain and its device classes never being selected at once.
|
||||
export const entityTypeFilterFunc = (
|
||||
types: string[],
|
||||
states: HomeAssistant["states"]
|
||||
): ((entityId: string) => boolean) => {
|
||||
const domains = new Set<string>();
|
||||
const deviceClasses = new Map<string, Set<string>>();
|
||||
|
||||
for (const key of types) {
|
||||
const { domain, deviceClass } = parseEntityType(key);
|
||||
if (deviceClass === undefined) {
|
||||
domains.add(domain);
|
||||
} else {
|
||||
let classes = deviceClasses.get(domain);
|
||||
if (!classes) {
|
||||
classes = new Set();
|
||||
deviceClasses.set(domain, classes);
|
||||
}
|
||||
classes.add(deviceClass);
|
||||
}
|
||||
}
|
||||
|
||||
return (entityId: string) => {
|
||||
const domain = computeDomain(entityId);
|
||||
if (domains.has(domain)) {
|
||||
return true;
|
||||
}
|
||||
const classes = deviceClasses.get(domain);
|
||||
if (!classes) {
|
||||
return false;
|
||||
}
|
||||
const stateObj = states[entityId];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
return classes.has(stateObj.attributes.device_class || NO_DEVICE_CLASS);
|
||||
};
|
||||
};
|
||||
+34
-1
@@ -1,9 +1,33 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { CallWS, HomeAssistant } from "../types";
|
||||
|
||||
export interface ESPHomeEncryptionKey {
|
||||
encryption_key: string;
|
||||
}
|
||||
|
||||
export type ESPHomeSerialPortType = "TTL" | "RS232" | "RS485";
|
||||
|
||||
export interface ESPHomeBluetoothProxyCapabilities {
|
||||
supported: boolean;
|
||||
}
|
||||
|
||||
export interface ESPHomeZWaveProxyCapabilities {
|
||||
supported: boolean;
|
||||
home_id: number;
|
||||
}
|
||||
|
||||
export interface ESPHomeSerialProxy {
|
||||
name: string;
|
||||
port_type: ESPHomeSerialPortType | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ESPHomeDeviceCapabilities {
|
||||
available: boolean;
|
||||
bluetooth_proxy: ESPHomeBluetoothProxyCapabilities;
|
||||
zwave_proxy: ESPHomeZWaveProxyCapabilities;
|
||||
serial_proxies: ESPHomeSerialProxy[];
|
||||
}
|
||||
|
||||
export const fetchESPHomeEncryptionKey = (
|
||||
hass: HomeAssistant,
|
||||
entry_id: string
|
||||
@@ -12,3 +36,12 @@ export const fetchESPHomeEncryptionKey = (
|
||||
type: "esphome/get_encryption_key",
|
||||
entry_id,
|
||||
});
|
||||
|
||||
export const fetchESPHomeDeviceCapabilities = (
|
||||
hass: { callWS: CallWS },
|
||||
deviceId: string
|
||||
): Promise<ESPHomeDeviceCapabilities> =>
|
||||
hass.callWS({
|
||||
type: "esphome/get_device_capabilities",
|
||||
device_id: deviceId,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import type { ConfigEntry } from "./config_entries";
|
||||
import type { DeviceRegistryEntry } from "./device/device_registry";
|
||||
import type { ESPHomeDeviceCapabilities } from "./esphome";
|
||||
import type { ESPHomeFrontendUserData } from "./frontend";
|
||||
|
||||
export const MUSIC_ASSISTANT_ADDON_SLUG = "d5369777_music_assistant";
|
||||
|
||||
export const MUSIC_ASSISTANT_DOCS_URL = "https://music-assistant.io/";
|
||||
|
||||
export const ESPHOME_SERIAL_INTEGRATIONS = [
|
||||
"anthemav",
|
||||
"monoprice",
|
||||
"russound_rio",
|
||||
"jvc_projector",
|
||||
"elkm1",
|
||||
] as const;
|
||||
|
||||
export type ESPHomeSerialIntegrationDomain =
|
||||
(typeof ESPHOME_SERIAL_INTEGRATIONS)[number];
|
||||
|
||||
export type ESPHomeCapabilityId =
|
||||
"bluetooth" | "audio" | "connectivity" | "serial";
|
||||
|
||||
export type ESPHomeCapabilityStatus =
|
||||
"not-started" | "active" | "detected" | "completed";
|
||||
|
||||
export interface ESPHomeSetupStatus {
|
||||
bluetooth?: "completed";
|
||||
audio?: "active" | "completed";
|
||||
connectivity?: "not-started" | "detected" | "completed";
|
||||
serial?: "not-started";
|
||||
}
|
||||
|
||||
export const CAPABILITY_ORDER: ESPHomeCapabilityId[] = [
|
||||
"bluetooth",
|
||||
"audio",
|
||||
"connectivity",
|
||||
"serial",
|
||||
];
|
||||
|
||||
/** Capability accents from the ESPHome device setup prototype. */
|
||||
export const ESPHOME_CAPABILITY_ACCENTS: Record<ESPHomeCapabilityId, string> = {
|
||||
bluetooth: "#2962ff",
|
||||
audio: "#53c22b",
|
||||
connectivity: "#00acc1",
|
||||
serial: "#8353d1",
|
||||
};
|
||||
|
||||
export const deviceHasMediaPlayerEntity = (
|
||||
deviceId: string,
|
||||
entities: Iterable<{ entity_id: string; device_id?: string | null }>
|
||||
): boolean => {
|
||||
for (const entity of entities) {
|
||||
if (
|
||||
entity.device_id === deviceId &&
|
||||
computeDomain(entity.entity_id) === "media_player"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const hasESPHomeSetupCapabilities = (
|
||||
capabilities: ESPHomeDeviceCapabilities | undefined | null,
|
||||
options: { mediaPlayerSupported?: boolean } = {}
|
||||
): boolean =>
|
||||
Boolean(
|
||||
capabilities &&
|
||||
(capabilities.bluetooth_proxy.supported ||
|
||||
options.mediaPlayerSupported ||
|
||||
capabilities.zwave_proxy.supported ||
|
||||
capabilities.serial_proxies.length > 0)
|
||||
);
|
||||
|
||||
export const hasZWaveJSEntryForDevice = (
|
||||
deviceId: string,
|
||||
devices: Record<string, DeviceRegistryEntry>,
|
||||
entries: ConfigEntry[]
|
||||
): boolean => {
|
||||
const zwaveEntryIds = new Set(
|
||||
entries
|
||||
.filter((entry) => entry.domain === "zwave_js" && !entry.disabled_by)
|
||||
.map((entry) => entry.entry_id)
|
||||
);
|
||||
if (!zwaveEntryIds.size) {
|
||||
return false;
|
||||
}
|
||||
const device = devices[deviceId];
|
||||
if (device?.config_entries.some((entryId) => zwaveEntryIds.has(entryId))) {
|
||||
return true;
|
||||
}
|
||||
return Object.values(devices).some(
|
||||
(candidate) =>
|
||||
candidate.via_device_id === deviceId &&
|
||||
candidate.config_entries.some((entryId) => zwaveEntryIds.has(entryId))
|
||||
);
|
||||
};
|
||||
|
||||
export const deriveESPHomeSetupStatus = (
|
||||
capabilities: ESPHomeDeviceCapabilities,
|
||||
options: {
|
||||
mediaPlayerSupported: boolean;
|
||||
musicAssistantLoaded: boolean;
|
||||
zwaveJsEntryExists: boolean;
|
||||
}
|
||||
): ESPHomeSetupStatus => {
|
||||
const status: ESPHomeSetupStatus = {};
|
||||
|
||||
if (capabilities.bluetooth_proxy.supported) {
|
||||
status.bluetooth = "completed";
|
||||
}
|
||||
|
||||
if (options.mediaPlayerSupported) {
|
||||
status.audio = options.musicAssistantLoaded ? "completed" : "active";
|
||||
}
|
||||
|
||||
if (capabilities.zwave_proxy.supported) {
|
||||
if (options.zwaveJsEntryExists) {
|
||||
status.connectivity = "completed";
|
||||
} else if (capabilities.zwave_proxy.home_id !== 0) {
|
||||
status.connectivity = "detected";
|
||||
} else {
|
||||
status.connectivity = "not-started";
|
||||
}
|
||||
}
|
||||
|
||||
if (capabilities.serial_proxies.length > 0) {
|
||||
status.serial = "not-started";
|
||||
}
|
||||
|
||||
return status;
|
||||
};
|
||||
|
||||
export const getESPHomeSetupCapabilityIds = (
|
||||
status: ESPHomeSetupStatus
|
||||
): ESPHomeCapabilityId[] =>
|
||||
CAPABILITY_ORDER.filter((id) => status[id] !== undefined);
|
||||
|
||||
export const countRemainingESPHomeCapabilities = (
|
||||
status: ESPHomeSetupStatus
|
||||
): number =>
|
||||
getESPHomeSetupCapabilityIds(status).filter(
|
||||
(id) => status[id] !== "completed"
|
||||
).length;
|
||||
|
||||
export const hasStartedNonBluetoothESPHomeSetup = (
|
||||
status: ESPHomeSetupStatus
|
||||
): boolean =>
|
||||
status.audio === "completed" || status.connectivity === "completed";
|
||||
|
||||
export const isESPHomeSetupDeferred = (
|
||||
data: ESPHomeFrontendUserData | null | undefined,
|
||||
deviceId: string
|
||||
): boolean => Boolean(data?.setupDeferred?.includes(deviceId));
|
||||
|
||||
export const withDeferredESPHomeDevice = (
|
||||
data: ESPHomeFrontendUserData | null | undefined,
|
||||
deviceId: string
|
||||
): ESPHomeFrontendUserData => ({
|
||||
...data,
|
||||
setupDeferred: [...new Set([...(data?.setupDeferred ?? []), deviceId])],
|
||||
});
|
||||
@@ -18,6 +18,10 @@ export interface SidebarFrontendUserData {
|
||||
hiddenPanels?: string[];
|
||||
}
|
||||
|
||||
export interface ESPHomeFrontendUserData {
|
||||
setupDeferred?: string[];
|
||||
}
|
||||
|
||||
export interface CoreFrontendSystemData {
|
||||
default_panel?: string;
|
||||
onboarded_version?: string;
|
||||
@@ -46,6 +50,7 @@ declare global {
|
||||
interface FrontendUserData {
|
||||
core: CoreFrontendUserData;
|
||||
sidebar: SidebarFrontendUserData;
|
||||
esphome: ESPHomeFrontendUserData;
|
||||
}
|
||||
interface FrontendSystemData {
|
||||
core: CoreFrontendSystemData;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { DEFAULT_ENTITY_NAME } from "../../common/entity/compute_entity_name_display";
|
||||
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { Selector } from "../selector";
|
||||
@@ -83,7 +82,10 @@ export const formatSelectorValue = (
|
||||
if (!stateObj) {
|
||||
return entityId;
|
||||
}
|
||||
const name = hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
|
||||
const name = hass.formatEntityName(stateObj, [
|
||||
{ type: "device" },
|
||||
{ type: "entity" },
|
||||
]);
|
||||
return name || entityId;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "./sensor_numeric_device_classes";
|
||||
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "./sensor_entity_constants";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export const SENSOR_DEVICE_CLASS_BATTERY = "battery";
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
// This file is auto-generated from Home Assistant Core's `DEVICE_CLASS_UNITS`
|
||||
// and `STATE_CLASS_UNITS`) and `SensorDeviceClass`
|
||||
// (all values minus `NON_NUMERIC_DEVICE_CLASSES`). Do not edit by hand.
|
||||
// Regenerate with `script/gen_sensor_entity_constants`.
|
||||
|
||||
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
|
||||
"absolute_humidity",
|
||||
"apparent_power",
|
||||
"aqi",
|
||||
"area",
|
||||
"atmospheric_pressure",
|
||||
"battery",
|
||||
"blood_glucose_concentration",
|
||||
"carbon_dioxide",
|
||||
"carbon_monoxide",
|
||||
"conductivity",
|
||||
"current",
|
||||
"data_rate",
|
||||
"data_size",
|
||||
"distance",
|
||||
"duration",
|
||||
"energy",
|
||||
"energy_distance",
|
||||
"energy_storage",
|
||||
"frequency",
|
||||
"gas",
|
||||
"humidity",
|
||||
"illuminance",
|
||||
"irradiance",
|
||||
"moisture",
|
||||
"monetary",
|
||||
"nitrogen_dioxide",
|
||||
"nitrogen_monoxide",
|
||||
"nitrous_oxide",
|
||||
"ozone",
|
||||
"ph",
|
||||
"pm1",
|
||||
"pm10",
|
||||
"pm25",
|
||||
"pm4",
|
||||
"power",
|
||||
"power_factor",
|
||||
"precipitation",
|
||||
"precipitation_intensity",
|
||||
"pressure",
|
||||
"radon",
|
||||
"reactive_energy",
|
||||
"reactive_power",
|
||||
"signal_strength",
|
||||
"sound_pressure",
|
||||
"speed",
|
||||
"sulphur_dioxide",
|
||||
"temperature",
|
||||
"temperature_delta",
|
||||
"volatile_organic_compounds",
|
||||
"volatile_organic_compounds_parts",
|
||||
"voltage",
|
||||
"volume",
|
||||
"volume_flow_rate",
|
||||
"volume_storage",
|
||||
"water",
|
||||
"weight",
|
||||
"wind_direction",
|
||||
"wind_speed",
|
||||
];
|
||||
|
||||
export const SENSOR_DEVICE_CLASS_UNITS: Record<string, (string | null)[]> = {
|
||||
absolute_humidity: ["g/m³", "mg/m³"],
|
||||
apparent_power: ["kVA", "mVA", "VA"],
|
||||
aqi: [null],
|
||||
area: ["ac", "cm²", "ft²", "ha", "in²", "km²", "mi²", "mm²", "m²", "yd²"],
|
||||
atmospheric_pressure: [
|
||||
"bar",
|
||||
"cbar",
|
||||
"hPa",
|
||||
"inHg",
|
||||
"inH₂O",
|
||||
"kPa",
|
||||
"mbar",
|
||||
"mmHg",
|
||||
"mPa",
|
||||
"Pa",
|
||||
"psi",
|
||||
],
|
||||
battery: ["%"],
|
||||
blood_glucose_concentration: ["mg/dL", "mmol/L"],
|
||||
carbon_monoxide: ["mg/m³", "ppb", "ppm", "μg/m³"],
|
||||
carbon_dioxide: ["ppm"],
|
||||
conductivity: ["mS/cm", "S/cm", "μS/cm"],
|
||||
current: ["A", "mA", "μA"],
|
||||
data_rate: [
|
||||
"B/s",
|
||||
"bit/s",
|
||||
"GB/s",
|
||||
"Gbit/s",
|
||||
"GiB/s",
|
||||
"kB/s",
|
||||
"kbit/s",
|
||||
"KiB/s",
|
||||
"MB/s",
|
||||
"Mbit/s",
|
||||
"MiB/s",
|
||||
],
|
||||
data_size: [
|
||||
"B",
|
||||
"bit",
|
||||
"EB",
|
||||
"EiB",
|
||||
"GB",
|
||||
"Gbit",
|
||||
"GiB",
|
||||
"kB",
|
||||
"kbit",
|
||||
"KiB",
|
||||
"MB",
|
||||
"Mbit",
|
||||
"MiB",
|
||||
"PB",
|
||||
"PiB",
|
||||
"TB",
|
||||
"TiB",
|
||||
"YB",
|
||||
"YiB",
|
||||
"ZB",
|
||||
"ZiB",
|
||||
],
|
||||
distance: ["cm", "ft", "in", "km", "m", "mi", "mm", "nmi", "yd"],
|
||||
duration: ["d", "h", "min", "ms", "s", "μs"],
|
||||
energy: [
|
||||
"cal",
|
||||
"Gcal",
|
||||
"GJ",
|
||||
"GWh",
|
||||
"J",
|
||||
"kcal",
|
||||
"kJ",
|
||||
"kWh",
|
||||
"Mcal",
|
||||
"MJ",
|
||||
"MWh",
|
||||
"mWh",
|
||||
"TWh",
|
||||
"Wh",
|
||||
],
|
||||
energy_distance: ["km/kWh", "kWh/100km", "mi/kWh", "Wh/km"],
|
||||
energy_storage: [
|
||||
"cal",
|
||||
"Gcal",
|
||||
"GJ",
|
||||
"GWh",
|
||||
"J",
|
||||
"kcal",
|
||||
"kJ",
|
||||
"kWh",
|
||||
"Mcal",
|
||||
"MJ",
|
||||
"MWh",
|
||||
"mWh",
|
||||
"TWh",
|
||||
"Wh",
|
||||
],
|
||||
frequency: ["GHz", "Hz", "kHz", "MHz", "mHz"],
|
||||
gas: ["CCF", "ft³", "L", "MCF", "m³"],
|
||||
humidity: ["%"],
|
||||
illuminance: ["lx"],
|
||||
irradiance: ["BTU/(h⋅ft²)", "W/m²"],
|
||||
moisture: ["%"],
|
||||
nitrogen_dioxide: ["ppb", "ppm", "μg/m³"],
|
||||
nitrogen_monoxide: ["ppb", "μg/m³"],
|
||||
nitrous_oxide: ["μg/m³"],
|
||||
ozone: ["ppb", "ppm", "μg/m³"],
|
||||
ph: [null],
|
||||
pm1: ["μg/m³"],
|
||||
pm10: ["μg/m³"],
|
||||
pm25: ["μg/m³"],
|
||||
pm4: ["μg/m³"],
|
||||
power_factor: ["%", null],
|
||||
power: ["GW", "kW", "MW", "mW", "TW", "W"],
|
||||
precipitation: ["cm", "in", "mm"],
|
||||
precipitation_intensity: ["in/d", "in/h", "mm/d", "mm/h"],
|
||||
pressure: [
|
||||
"bar",
|
||||
"cbar",
|
||||
"hPa",
|
||||
"inHg",
|
||||
"inH₂O",
|
||||
"kPa",
|
||||
"mbar",
|
||||
"mmHg",
|
||||
"mPa",
|
||||
"Pa",
|
||||
"psi",
|
||||
],
|
||||
radon: ["Bq/m³", "pCi/L"],
|
||||
reactive_energy: ["kvarh", "varh"],
|
||||
reactive_power: ["kvar", "mvar", "var"],
|
||||
signal_strength: ["dB", "dBm"],
|
||||
sound_pressure: ["dB", "dBA"],
|
||||
speed: [
|
||||
"Beaufort",
|
||||
"ft/s",
|
||||
"in/d",
|
||||
"in/h",
|
||||
"in/s",
|
||||
"km/h",
|
||||
"kn",
|
||||
"m/min",
|
||||
"m/s",
|
||||
"mm/d",
|
||||
"mm/h",
|
||||
"mm/s",
|
||||
"mph",
|
||||
],
|
||||
sulphur_dioxide: ["ppb", "μg/m³"],
|
||||
temperature: ["K", "°C", "°F"],
|
||||
temperature_delta: ["K", "°C", "°F"],
|
||||
volatile_organic_compounds: ["mg/m³", "μg/m³"],
|
||||
volatile_organic_compounds_parts: ["ppb", "ppm"],
|
||||
voltage: ["kV", "MV", "mV", "V", "μV"],
|
||||
volume: ["CCF", "fl. oz.", "ft³", "gal", "L", "MCF", "mL", "m³"],
|
||||
volume_flow_rate: [
|
||||
"ft³/min",
|
||||
"gal/d",
|
||||
"gal/h",
|
||||
"gal/min",
|
||||
"L/h",
|
||||
"L/min",
|
||||
"L/s",
|
||||
"mL/s",
|
||||
"m³/h",
|
||||
"m³/min",
|
||||
"m³/s",
|
||||
],
|
||||
volume_storage: ["CCF", "fl. oz.", "ft³", "gal", "L", "MCF", "mL", "m³"],
|
||||
water: ["CCF", "ft³", "gal", "L", "MCF", "m³"],
|
||||
weight: ["g", "kg", "lb", "mg", "oz", "st", "μg"],
|
||||
wind_direction: ["°"],
|
||||
wind_speed: [
|
||||
"Beaufort",
|
||||
"ft/s",
|
||||
"in/s",
|
||||
"km/h",
|
||||
"kn",
|
||||
"m/min",
|
||||
"m/s",
|
||||
"mm/s",
|
||||
"mph",
|
||||
],
|
||||
};
|
||||
|
||||
export const SENSOR_DEVICE_CLASS_CONVERTIBLE_UNITS: Record<
|
||||
string,
|
||||
(string | null)[]
|
||||
> = {
|
||||
absolute_humidity: ["g/m³", "mg/m³"],
|
||||
apparent_power: ["kVA", "mVA", "VA"],
|
||||
area: ["ac", "cm²", "ft²", "ha", "in²", "km²", "mi²", "mm²", "m²", "yd²"],
|
||||
atmospheric_pressure: [
|
||||
"bar",
|
||||
"cbar",
|
||||
"hPa",
|
||||
"inHg",
|
||||
"inH₂O",
|
||||
"kPa",
|
||||
"mbar",
|
||||
"mmHg",
|
||||
"mPa",
|
||||
"Pa",
|
||||
"psi",
|
||||
],
|
||||
blood_glucose_concentration: ["mg/dL", "mmol/L"],
|
||||
carbon_monoxide: ["mg/m³", "ppb", "ppm", "μg/m³"],
|
||||
conductivity: ["mS/cm", "S/cm", "μS/cm"],
|
||||
current: ["A", "mA", "μA"],
|
||||
data_rate: [
|
||||
"B/s",
|
||||
"bit/s",
|
||||
"GB/s",
|
||||
"Gbit/s",
|
||||
"GiB/s",
|
||||
"kB/s",
|
||||
"kbit/s",
|
||||
"KiB/s",
|
||||
"MB/s",
|
||||
"Mbit/s",
|
||||
"MiB/s",
|
||||
],
|
||||
data_size: [
|
||||
"B",
|
||||
"bit",
|
||||
"EB",
|
||||
"EiB",
|
||||
"GB",
|
||||
"Gbit",
|
||||
"GiB",
|
||||
"kB",
|
||||
"kbit",
|
||||
"KiB",
|
||||
"MB",
|
||||
"Mbit",
|
||||
"MiB",
|
||||
"PB",
|
||||
"PiB",
|
||||
"TB",
|
||||
"TiB",
|
||||
"YB",
|
||||
"YiB",
|
||||
"ZB",
|
||||
"ZiB",
|
||||
],
|
||||
distance: ["cm", "ft", "in", "km", "m", "mi", "mm", "nmi", "yd"],
|
||||
duration: ["d", "h", "min", "ms", "s", "μs"],
|
||||
energy: [
|
||||
"cal",
|
||||
"Gcal",
|
||||
"GJ",
|
||||
"GWh",
|
||||
"J",
|
||||
"kcal",
|
||||
"kJ",
|
||||
"kWh",
|
||||
"Mcal",
|
||||
"MJ",
|
||||
"MWh",
|
||||
"mWh",
|
||||
"TWh",
|
||||
"Wh",
|
||||
],
|
||||
energy_distance: ["km/kWh", "kWh/100km", "mi/kWh", "Wh/km"],
|
||||
energy_storage: [
|
||||
"cal",
|
||||
"Gcal",
|
||||
"GJ",
|
||||
"GWh",
|
||||
"J",
|
||||
"kcal",
|
||||
"kJ",
|
||||
"kWh",
|
||||
"Mcal",
|
||||
"MJ",
|
||||
"MWh",
|
||||
"mWh",
|
||||
"TWh",
|
||||
"Wh",
|
||||
],
|
||||
frequency: ["GHz", "Hz", "kHz", "MHz", "mHz"],
|
||||
gas: ["CCF", "ft³", "L", "MCF", "m³"],
|
||||
nitrogen_dioxide: ["ppb", "ppm", "μg/m³"],
|
||||
nitrogen_monoxide: ["ppb", "μg/m³"],
|
||||
ozone: ["ppb", "ppm", "μg/m³"],
|
||||
power: ["GW", "kW", "MW", "mW", "TW", "W"],
|
||||
power_factor: ["%", null],
|
||||
precipitation: ["cm", "in", "mm"],
|
||||
precipitation_intensity: ["in/d", "in/h", "mm/d", "mm/h"],
|
||||
pressure: [
|
||||
"bar",
|
||||
"cbar",
|
||||
"hPa",
|
||||
"inHg",
|
||||
"inH₂O",
|
||||
"kPa",
|
||||
"mbar",
|
||||
"mmHg",
|
||||
"mPa",
|
||||
"Pa",
|
||||
"psi",
|
||||
],
|
||||
radon: ["Bq/m³", "pCi/L"],
|
||||
reactive_energy: ["kvarh", "varh"],
|
||||
reactive_power: ["kvar", "mvar", "var"],
|
||||
speed: [
|
||||
"Beaufort",
|
||||
"ft/s",
|
||||
"in/d",
|
||||
"in/h",
|
||||
"in/s",
|
||||
"km/h",
|
||||
"kn",
|
||||
"m/min",
|
||||
"m/s",
|
||||
"mm/d",
|
||||
"mm/h",
|
||||
"mm/s",
|
||||
"mph",
|
||||
],
|
||||
sulphur_dioxide: ["ppb", "μg/m³"],
|
||||
temperature: ["K", "°C", "°F"],
|
||||
temperature_delta: ["K", "°C", "°F"],
|
||||
volatile_organic_compounds: ["mg/m³", "μg/m³"],
|
||||
volatile_organic_compounds_parts: ["ppb", "ppm"],
|
||||
voltage: ["kV", "MV", "mV", "V", "μV"],
|
||||
volume: ["CCF", "fl. oz.", "ft³", "gal", "L", "MCF", "mL", "m³"],
|
||||
volume_flow_rate: [
|
||||
"ft³/min",
|
||||
"gal/d",
|
||||
"gal/h",
|
||||
"gal/min",
|
||||
"L/h",
|
||||
"L/min",
|
||||
"L/s",
|
||||
"mL/s",
|
||||
"m³/h",
|
||||
"m³/min",
|
||||
"m³/s",
|
||||
],
|
||||
volume_storage: ["CCF", "fl. oz.", "ft³", "gal", "L", "MCF", "mL", "m³"],
|
||||
water: ["CCF", "ft³", "gal", "L", "MCF", "m³"],
|
||||
weight: ["g", "kg", "lb", "mg", "oz", "st", "μg"],
|
||||
wind_speed: [
|
||||
"Beaufort",
|
||||
"ft/s",
|
||||
"in/s",
|
||||
"km/h",
|
||||
"kn",
|
||||
"m/min",
|
||||
"m/s",
|
||||
"mm/s",
|
||||
"mph",
|
||||
],
|
||||
};
|
||||
|
||||
export const SENSOR_STATE_CLASSES: string[] = [
|
||||
"measurement",
|
||||
"measurement_angle",
|
||||
"total",
|
||||
"total_increasing",
|
||||
];
|
||||
|
||||
export const SENSOR_STATE_CLASS_UNITS: Record<string, string[]> = {
|
||||
measurement_angle: ["°"],
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
// This file is auto-generated from Home Assistant Core's `SensorDeviceClass`
|
||||
// (all values minus `NON_NUMERIC_DEVICE_CLASSES`). Do not edit by hand.
|
||||
// Regenerate with `script/gen_numeric_device_classes`.
|
||||
|
||||
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
|
||||
"absolute_humidity",
|
||||
"apparent_power",
|
||||
"aqi",
|
||||
"area",
|
||||
"atmospheric_pressure",
|
||||
"battery",
|
||||
"blood_glucose_concentration",
|
||||
"carbon_dioxide",
|
||||
"carbon_monoxide",
|
||||
"conductivity",
|
||||
"current",
|
||||
"data_rate",
|
||||
"data_size",
|
||||
"distance",
|
||||
"duration",
|
||||
"energy",
|
||||
"energy_distance",
|
||||
"energy_storage",
|
||||
"frequency",
|
||||
"gas",
|
||||
"humidity",
|
||||
"illuminance",
|
||||
"irradiance",
|
||||
"moisture",
|
||||
"monetary",
|
||||
"nitrogen_dioxide",
|
||||
"nitrogen_monoxide",
|
||||
"nitrous_oxide",
|
||||
"ozone",
|
||||
"ph",
|
||||
"pm1",
|
||||
"pm10",
|
||||
"pm25",
|
||||
"pm4",
|
||||
"power",
|
||||
"power_factor",
|
||||
"precipitation",
|
||||
"precipitation_intensity",
|
||||
"pressure",
|
||||
"radon",
|
||||
"reactive_energy",
|
||||
"reactive_power",
|
||||
"signal_strength",
|
||||
"sound_pressure",
|
||||
"speed",
|
||||
"sulphur_dioxide",
|
||||
"temperature",
|
||||
"temperature_delta",
|
||||
"volatile_organic_compounds",
|
||||
"volatile_organic_compounds_parts",
|
||||
"voltage",
|
||||
"volume",
|
||||
"volume_flow_rate",
|
||||
"volume_storage",
|
||||
"water",
|
||||
"weight",
|
||||
"wind_direction",
|
||||
"wind_speed",
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { ESPHomeDeviceCapabilities } from "../../data/esphome";
|
||||
|
||||
export const loadESPHomeDeviceSetupDialog = () =>
|
||||
import("./dialog-esphome-device-setup");
|
||||
|
||||
export interface ESPHomeDeviceSetupDialogParams {
|
||||
deviceId: string;
|
||||
deviceName?: string;
|
||||
capabilities?: ESPHomeDeviceCapabilities;
|
||||
mediaPlayerSupported?: boolean;
|
||||
dialogClosedCallback?: () => void;
|
||||
}
|
||||
|
||||
export const showESPHomeDeviceSetupDialog = (
|
||||
element: HTMLElement,
|
||||
dialogParams: ESPHomeDeviceSetupDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-esphome-device-setup",
|
||||
dialogImport: loadESPHomeDeviceSetupDialog,
|
||||
dialogParams,
|
||||
});
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import deepClone from "deep-clone-simple";
|
||||
import { deepActiveElement } from "../../common/dom/deep-active-element";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { nextRender } from "../../common/util/render-status";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-dialog";
|
||||
import "../../components/ha-dialog-footer";
|
||||
@@ -123,6 +124,7 @@ export class DialogForm
|
||||
this._initialData = deepClone(this._data);
|
||||
this._error = undefined;
|
||||
this._resetDirtyTracking();
|
||||
void this._focusActiveForm(nested);
|
||||
};
|
||||
|
||||
private _popStack(): StackEntry | undefined {
|
||||
@@ -142,16 +144,111 @@ export class DialogForm
|
||||
return prev;
|
||||
}
|
||||
|
||||
private async _afterFormRender(): Promise<void> {
|
||||
await this.updateComplete;
|
||||
await this._form?.updateComplete;
|
||||
await nextRender();
|
||||
}
|
||||
|
||||
private async _waitForSelectorElements(): Promise<void> {
|
||||
const selectors = this._form?.shadowRoot?.querySelectorAll("ha-selector");
|
||||
if (selectors?.length) {
|
||||
await Promise.all(
|
||||
Array.from(selectors, (element) =>
|
||||
"updateComplete" in element
|
||||
? (element as LitElement).updateComplete
|
||||
: undefined
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const pending = this._undefinedCustomElements(this._form);
|
||||
if (!pending.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(pending.map((tag) => customElements.whenDefined(tag)));
|
||||
await nextRender();
|
||||
}
|
||||
|
||||
private _undefinedCustomElements(root?: ParentNode): string[] {
|
||||
const tags = new Set<string>();
|
||||
const visit = (node: ParentNode) => {
|
||||
if (node instanceof Element && node.shadowRoot) {
|
||||
visit(node.shadowRoot);
|
||||
}
|
||||
for (const child of node.children) {
|
||||
if (
|
||||
child.localName.includes("-") &&
|
||||
!customElements.get(child.localName)
|
||||
) {
|
||||
tags.add(child.localName);
|
||||
}
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
if (root) {
|
||||
visit(root);
|
||||
}
|
||||
return [...tags];
|
||||
}
|
||||
|
||||
private _focusFirstControl(root = this._form): void {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visit = (node: ParentNode): HTMLElement | undefined => {
|
||||
if (node instanceof Element && node.shadowRoot) {
|
||||
const inShadow = visit(node.shadowRoot);
|
||||
if (inShadow) {
|
||||
return inShadow;
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
if (
|
||||
child instanceof HTMLElement &&
|
||||
child.matches("input, textarea, select, button")
|
||||
) {
|
||||
return child;
|
||||
}
|
||||
const found = visit(child);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
visit(root)?.focus();
|
||||
}
|
||||
|
||||
private async _focusActiveForm(
|
||||
expectedParams: FormDialogParams
|
||||
): Promise<void> {
|
||||
await this._afterFormRender();
|
||||
|
||||
if (!this._open || this._params !== expectedParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this._waitForSelectorElements();
|
||||
|
||||
if (!this._open || this._params !== expectedParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._focusFirstControl();
|
||||
}
|
||||
|
||||
private async _restoreFocusAndScroll(
|
||||
scrollTop: number,
|
||||
expectedParams: FormDialogParams,
|
||||
focusTarget?: Element
|
||||
): Promise<void> {
|
||||
await this.updateComplete;
|
||||
await this._form?.updateComplete;
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
await this._afterFormRender();
|
||||
|
||||
if (!this._open || this._params !== expectedParams || !this._dialog) {
|
||||
return;
|
||||
@@ -159,6 +256,8 @@ export class DialogForm
|
||||
|
||||
if (focusTarget instanceof HTMLElement && focusTarget.isConnected) {
|
||||
focusTarget.focus();
|
||||
} else {
|
||||
this._focusFirstControl();
|
||||
}
|
||||
|
||||
this._dialog.bodyContainer.scrollTop = scrollTop;
|
||||
|
||||
@@ -73,8 +73,11 @@ class HaMoreInfoDetails extends LitElement {
|
||||
stateEntries,
|
||||
attributes,
|
||||
yamlData: stateYamlData,
|
||||
} = this._getDetailData(this._stateObj);
|
||||
const { floor, area, device, parentDevice } = getEntityContext(
|
||||
} = this._getDetailData(
|
||||
this._stateObj,
|
||||
this.hass.formatEntityAttributeName
|
||||
);
|
||||
const { floor, area, device } = getEntityContext(
|
||||
this._stateObj,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
@@ -83,13 +86,6 @@ class HaMoreInfoDetails 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;
|
||||
@@ -119,13 +115,6 @@ class HaMoreInfoDetails extends LitElement {
|
||||
href: `/config/areas/area/${area.area_id}`,
|
||||
});
|
||||
}
|
||||
if (parentDevice && parentDeviceName) {
|
||||
contextEntries.push({
|
||||
translationKey: "ui.dialogs.more_info_control.parent_device",
|
||||
value: parentDeviceName,
|
||||
href: `/config/devices/device/${parentDevice.id}`,
|
||||
});
|
||||
}
|
||||
if (device && deviceName) {
|
||||
contextEntries.push({
|
||||
translationKey: "ui.components.related-items.device",
|
||||
@@ -159,7 +148,6 @@ class HaMoreInfoDetails extends LitElement {
|
||||
context: {
|
||||
...(floorName ? { floor: floorName } : {}),
|
||||
...(areaName ? { area: areaName } : {}),
|
||||
...(parentDeviceName ? { parent_device: parentDeviceName } : {}),
|
||||
...(deviceName ? { device: deviceName } : {}),
|
||||
...(integrationName ? { integration: integrationName } : {}),
|
||||
},
|
||||
@@ -226,7 +214,10 @@ class HaMoreInfoDetails extends LitElement {
|
||||
|
||||
private _getDetailData = memoizeOne(
|
||||
(
|
||||
stateObj: HassEntity
|
||||
stateObj: HassEntity,
|
||||
// cache key only: a new function is assigned when translation-based
|
||||
// format functions reload, invalidating results formatted via this.hass
|
||||
_formatEntityAttributeName: HomeAssistant["formatEntityAttributeName"]
|
||||
): {
|
||||
stateEntries: DetailEntry[];
|
||||
attributes: { name: string; label: string }[];
|
||||
|
||||
@@ -615,17 +615,11 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
const deviceName = context?.device
|
||||
? computeDeviceName(context.device)
|
||||
: undefined;
|
||||
const parentDeviceName = context?.parentDevice
|
||||
? computeDeviceName(context.parentDevice)
|
||||
: undefined;
|
||||
const areaName = context?.area ? computeAreaName(context.area) : undefined;
|
||||
|
||||
const breadcrumb = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
deviceName,
|
||||
entityName,
|
||||
].filter((v): v is string => Boolean(v));
|
||||
const breadcrumb = [areaName, deviceName, entityName].filter(
|
||||
(v): v is string => Boolean(v)
|
||||
);
|
||||
const defaultTitle = breadcrumb.pop() || entityId;
|
||||
const addToTitle = this.hass.localize(
|
||||
"ui.dialogs.more_info_control.add_to.title",
|
||||
@@ -1035,10 +1029,14 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProps.has("_currView") || changedProps.has("_entry")) {
|
||||
if (this._currView === "settings" && this._entry) {
|
||||
this._initDirtyTracking({ type: "deep" });
|
||||
}
|
||||
if (
|
||||
this._currView === "settings" &&
|
||||
this._entry &&
|
||||
((changedProps.has("_currView") &&
|
||||
changedProps.get("_currView") !== "settings") ||
|
||||
(changedProps.has("_entry") && !changedProps.get("_entry")))
|
||||
) {
|
||||
this._initDirtyTracking({ type: "deep" });
|
||||
}
|
||||
|
||||
if (changedProps.has("_currView")) {
|
||||
|
||||
@@ -42,6 +42,24 @@ export const climateEntityFilters: EntityFilter[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const hasClimateEntities = (hass: HomeAssistant): boolean => {
|
||||
const hasAreaSensor = Object.values(hass.areas).some(
|
||||
(area) =>
|
||||
(area.temperature_entity_id && hass.states[area.temperature_entity_id]) ||
|
||||
(area.humidity_entity_id && hass.states[area.humidity_entity_id])
|
||||
);
|
||||
|
||||
if (hasAreaSensor) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const entityIds = Object.keys(hass.states);
|
||||
|
||||
return climateEntityFilters.some((filter) =>
|
||||
entityIds.some(generateEntityFilter(hass, filter))
|
||||
);
|
||||
};
|
||||
|
||||
const processAreasForClimate = (
|
||||
areaIds: string[],
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -977,26 +977,16 @@ class DialogAddAutomationElement
|
||||
);
|
||||
} else {
|
||||
const stateObj = this.hass.states[targetId];
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
subtitle = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
subtitle = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(
|
||||
computeRTL(
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import {
|
||||
mdiAccessPoint,
|
||||
mdiBluetooth,
|
||||
mdiCheck,
|
||||
mdiMusic,
|
||||
mdiSwapHorizontal,
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import {
|
||||
fireEvent,
|
||||
type HASSDomEvent,
|
||||
} from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import { internationalizationContext } from "../../../../data/context";
|
||||
import {
|
||||
ESPHOME_CAPABILITY_ACCENTS,
|
||||
getESPHomeSetupCapabilityIds,
|
||||
type ESPHomeCapabilityId,
|
||||
type ESPHomeSetupStatus,
|
||||
} from "../../../../data/esphome_setup";
|
||||
|
||||
const CAPABILITY_ICONS: Record<ESPHomeCapabilityId, string> = {
|
||||
bluetooth: mdiBluetooth,
|
||||
audio: mdiMusic,
|
||||
connectivity: mdiAccessPoint,
|
||||
serial: mdiSwapHorizontal,
|
||||
};
|
||||
|
||||
const CAPABILITY_TITLE_KEYS: Record<ESPHomeCapabilityId, LocalizeKeys> = {
|
||||
bluetooth: "ui.panel.config.devices.esphome.setup_capability_bluetooth_title",
|
||||
audio: "ui.panel.config.devices.esphome.setup_capability_audio_title",
|
||||
connectivity:
|
||||
"ui.panel.config.devices.esphome.setup_capability_connectivity_title",
|
||||
serial: "ui.panel.config.devices.esphome.setup_capability_serial_title",
|
||||
};
|
||||
|
||||
@customElement("ha-esphome-setup-banner")
|
||||
export class HaESPHomeSetupBanner extends LitElement {
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@property({ attribute: false }) public deviceName!: string;
|
||||
|
||||
@property({ attribute: false }) public status: ESPHomeSetupStatus = {};
|
||||
|
||||
@property({ type: Boolean }) public started = false;
|
||||
|
||||
protected render() {
|
||||
if (!this._i18n) {
|
||||
return nothing;
|
||||
}
|
||||
const localize = this._i18n.localize;
|
||||
const ids = getESPHomeSetupCapabilityIds(this.status);
|
||||
return html`
|
||||
<ha-card outlined>
|
||||
<div class="content">
|
||||
<div class="text">
|
||||
<h2>
|
||||
${
|
||||
this.started
|
||||
? localize(
|
||||
"ui.panel.config.devices.esphome.setup_continue_title",
|
||||
{ name: this.deviceName }
|
||||
)
|
||||
: localize("ui.panel.config.devices.esphome.setup_title")
|
||||
}
|
||||
</h2>
|
||||
<p>
|
||||
${localize("ui.panel.config.devices.esphome.setup_intro", {
|
||||
count: ids.length,
|
||||
})}
|
||||
</p>
|
||||
<div class="actions">
|
||||
<ha-button @click=${this._setup}>
|
||||
${localize("ui.panel.config.devices.esphome.setup_action")}
|
||||
</ha-button>
|
||||
<ha-button appearance="plain" @click=${this._later}>
|
||||
${localize("ui.panel.config.devices.esphome.setup_later")}
|
||||
</ha-button>
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
ids.length
|
||||
? html`
|
||||
<div class="pips">
|
||||
${ids.map((id) => this._renderPip(id))}
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderPip(id: ESPHomeCapabilityId) {
|
||||
const localize = this._i18n!.localize;
|
||||
const completed = this.status[id] === "completed";
|
||||
return html`
|
||||
<div class="pip">
|
||||
<span
|
||||
class="pip-chip"
|
||||
style="--capability-accent: ${ESPHOME_CAPABILITY_ACCENTS[id]}"
|
||||
>
|
||||
<ha-svg-icon .path=${CAPABILITY_ICONS[id]}></ha-svg-icon>
|
||||
${
|
||||
completed
|
||||
? html`
|
||||
<span
|
||||
class="pip-check"
|
||||
role="img"
|
||||
aria-label=${localize(
|
||||
"ui.panel.config.devices.esphome.setup_status_completed"
|
||||
)}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiCheck}></ha-svg-icon>
|
||||
</span>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</span>
|
||||
<span class="pip-label"> ${localize(CAPABILITY_TITLE_KEYS[id])} </span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _setup() {
|
||||
fireEvent(this, "esphome-setup");
|
||||
}
|
||||
|
||||
private _later() {
|
||||
fireEvent(this, "esphome-setup-later");
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
display: block;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-5);
|
||||
padding: var(--ha-space-5);
|
||||
}
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--ha-space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--ha-font-size-2xl);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
max-width: 48ch;
|
||||
color: var(--secondary-text-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-1);
|
||||
margin-block-start: var(--ha-space-2);
|
||||
}
|
||||
.pips {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--ha-space-3) var(--ha-space-6);
|
||||
align-content: center;
|
||||
}
|
||||
.pip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
.pip-chip {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
background: color-mix(in srgb, var(--capability-accent) 12%, transparent);
|
||||
color: var(--capability-accent);
|
||||
}
|
||||
.pip-chip ha-svg-icon {
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
.pip-check {
|
||||
position: absolute;
|
||||
inset-inline-end: -2px;
|
||||
bottom: -2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
background: var(--success-color);
|
||||
color: var(--ha-color-on-success-loud);
|
||||
border: 2px solid var(--card-background-color);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.pip-check ha-svg-icon {
|
||||
--mdc-icon-size: 10px;
|
||||
}
|
||||
.pip-label {
|
||||
min-width: 0;
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.content {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ha-space-8);
|
||||
}
|
||||
.text {
|
||||
flex: 1.05 1 16rem;
|
||||
}
|
||||
.pips {
|
||||
flex: 1 1 12rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-esphome-setup-banner": HaESPHomeSetupBanner;
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"esphome-setup": undefined;
|
||||
"esphome-setup-later": undefined;
|
||||
}
|
||||
|
||||
interface HTMLElementEventMap {
|
||||
"esphome-setup": HASSDomEvent<HASSDomEvents["esphome-setup"]>;
|
||||
"esphome-setup-later": HASSDomEvent<HASSDomEvents["esphome-setup-later"]>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import { internationalizationContext } from "../../../../data/context";
|
||||
|
||||
@customElement("ha-esphome-setup-reminder")
|
||||
export class HaESPHomeSetupReminder extends LitElement {
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@property({ type: Number }) public remaining = 0;
|
||||
|
||||
@property({ type: Number }) public count = 0;
|
||||
|
||||
protected render() {
|
||||
if (!this._i18n) {
|
||||
return nothing;
|
||||
}
|
||||
const localize = this._i18n.localize;
|
||||
const title =
|
||||
this.remaining > 0
|
||||
? localize("ui.panel.config.devices.esphome.setup_reminder_remaining", {
|
||||
count: this.remaining,
|
||||
})
|
||||
: localize("ui.panel.config.devices.esphome.setup_reminder_done");
|
||||
const lead =
|
||||
this.remaining > 0
|
||||
? localize("ui.panel.config.devices.esphome.setup_reminder_intro", {
|
||||
count: this.count,
|
||||
})
|
||||
: localize(
|
||||
"ui.panel.config.devices.esphome.setup_reminder_done_intro",
|
||||
{ count: this.count }
|
||||
);
|
||||
return html`
|
||||
<ha-card outlined .header=${title}>
|
||||
<div class="card-content">
|
||||
<p>${lead}</p>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button appearance="plain" @click=${this._setup}>
|
||||
${localize("ui.panel.config.devices.esphome.setup_action")}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _setup() {
|
||||
fireEvent(this, "esphome-setup");
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--secondary-text-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
}
|
||||
.card-content {
|
||||
padding-block-end: var(--ha-space-6);
|
||||
}
|
||||
/* Same as Device info slot="actions" on ha-config-device-page
|
||||
(and ha-device-entities-card). */
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--ha-space-1) var(--ha-space-4) var(--ha-space-1)
|
||||
var(--ha-space-1);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-esphome-setup-reminder": HaESPHomeSetupReminder;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
mdiTextureBox,
|
||||
mdiTools,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -83,12 +83,33 @@ import {
|
||||
findBatteryEntity,
|
||||
updateEntityRegistryEntry,
|
||||
} from "../../../data/entity/entity_registry";
|
||||
import {
|
||||
fetchESPHomeDeviceCapabilities,
|
||||
type ESPHomeDeviceCapabilities,
|
||||
} from "../../../data/esphome";
|
||||
import {
|
||||
countRemainingESPHomeCapabilities,
|
||||
deriveESPHomeSetupStatus,
|
||||
deviceHasMediaPlayerEntity,
|
||||
getESPHomeSetupCapabilityIds,
|
||||
hasESPHomeSetupCapabilities,
|
||||
hasStartedNonBluetoothESPHomeSetup,
|
||||
hasZWaveJSEntryForDevice,
|
||||
isESPHomeSetupDeferred,
|
||||
withDeferredESPHomeDevice,
|
||||
} from "../../../data/esphome_setup";
|
||||
import {
|
||||
saveFrontendUserData,
|
||||
subscribeFrontendUserData,
|
||||
type ESPHomeFrontendUserData,
|
||||
} from "../../../data/frontend";
|
||||
import type { IntegrationManifest } from "../../../data/integration";
|
||||
import { domainToName } from "../../../data/integration";
|
||||
import { regenerateEntityIds } from "../../../data/regenerate_entity_ids";
|
||||
import type { RelatedResult } from "../../../data/search";
|
||||
import { findRelated } from "../../../data/search";
|
||||
import { filterAddToSceneEntityIds } from "../../../dialogs/add-to/add-to";
|
||||
import { showESPHomeDeviceSetupDialog } from "../../../dialogs/esphome-device-setup/show-dialog-esphome-device-setup";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
@@ -110,6 +131,8 @@ import "../../logbook/ha-logbook";
|
||||
import "./device-detail/ha-device-child-devices-card";
|
||||
import "./device-detail/ha-device-entities-card";
|
||||
import "./device-detail/ha-device-info-card";
|
||||
import "./device-detail/ha-esphome-setup-banner";
|
||||
import "./device-detail/ha-esphome-setup-reminder";
|
||||
import "./device-detail/ha-device-linked-devices-card";
|
||||
import "./device-detail/ha-device-via-devices-card";
|
||||
import { showDeviceAddToDialog } from "./device-detail/show-dialog-device-add-to";
|
||||
@@ -206,8 +229,18 @@ export class HaConfigDevicePage extends LitElement {
|
||||
|
||||
@state() private _deviceAlerts: DeviceAlert[] = [];
|
||||
|
||||
@state() private _esphomeCapabilities?: ESPHomeDeviceCapabilities;
|
||||
|
||||
@state() private _esphomeUserData: ESPHomeFrontendUserData | null = null;
|
||||
|
||||
@state() private _esphomeUserDataReady = false;
|
||||
|
||||
private _deviceAlertsActionsTimeout?: number;
|
||||
|
||||
private _unsubEsphomeUserData?: UnsubscribeFunc;
|
||||
|
||||
private _esphomeUserDataSubGeneration = 0;
|
||||
|
||||
@state()
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
_entityReg: EntityRegistryEntry[] = [];
|
||||
@@ -367,6 +400,7 @@ export class HaConfigDevicePage extends LitElement {
|
||||
this._deviceAlerts = [];
|
||||
this._deleteButtons = [];
|
||||
this._diagnosticDownloadLinks = [];
|
||||
this._esphomeCapabilities = undefined;
|
||||
}
|
||||
|
||||
if (changedProps.has("deviceId") || changedProps.has("entries")) {
|
||||
@@ -377,6 +411,7 @@ export class HaConfigDevicePage extends LitElement {
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
loadDeviceRegistryDetailDialog();
|
||||
this._subscribeESPHomeUserData();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
@@ -390,9 +425,19 @@ export class HaConfigDevicePage extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated) {
|
||||
this._subscribeESPHomeUserData();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
clearTimeout(this._deviceAlertsActionsTimeout);
|
||||
this._esphomeUserDataSubGeneration += 1;
|
||||
this._unsubEsphomeUserData?.();
|
||||
this._unsubEsphomeUserData = undefined;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -446,6 +491,43 @@ export class HaConfigDevicePage extends LitElement {
|
||||
: undefined;
|
||||
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
|
||||
|
||||
const mediaPlayerSupported = deviceHasMediaPlayerEntity(
|
||||
this.deviceId,
|
||||
entities
|
||||
);
|
||||
const showESPHomeSetup =
|
||||
this._esphomeUserDataReady &&
|
||||
hasESPHomeSetupCapabilities(this._esphomeCapabilities, {
|
||||
mediaPlayerSupported,
|
||||
});
|
||||
const esphomeDeferred = isESPHomeSetupDeferred(
|
||||
this._esphomeUserData,
|
||||
this.deviceId
|
||||
);
|
||||
const esphomeStatus = this._esphomeCapabilities
|
||||
? deriveESPHomeSetupStatus(this._esphomeCapabilities, {
|
||||
mediaPlayerSupported,
|
||||
musicAssistantLoaded: isComponentLoaded(
|
||||
this.hass.config,
|
||||
"music_assistant"
|
||||
),
|
||||
zwaveJsEntryExists: hasZWaveJSEntryForDevice(
|
||||
this.deviceId,
|
||||
this.hass.devices,
|
||||
this.entries
|
||||
),
|
||||
})
|
||||
: undefined;
|
||||
const esphomeRemaining = esphomeStatus
|
||||
? countRemainingESPHomeCapabilities(esphomeStatus)
|
||||
: 0;
|
||||
const esphomeCapabilityCount = esphomeStatus
|
||||
? getESPHomeSetupCapabilityIds(esphomeStatus).length
|
||||
: 0;
|
||||
const esphomeStarted = esphomeStatus
|
||||
? hasStartedNonBluetoothESPHomeSetup(esphomeStatus)
|
||||
: false;
|
||||
|
||||
const deviceInfo: TemplateResult[] = integrations.length
|
||||
? [
|
||||
html`<ha-list-nav slot="actions">
|
||||
@@ -901,6 +983,15 @@ export class HaConfigDevicePage extends LitElement {
|
||||
: ""
|
||||
}
|
||||
</ha-device-info-card>
|
||||
${
|
||||
showESPHomeSetup && esphomeDeferred
|
||||
? html`<ha-esphome-setup-reminder
|
||||
.remaining=${esphomeRemaining}
|
||||
.count=${esphomeCapabilityCount}
|
||||
@esphome-setup=${this._showESPHomeSetup}
|
||||
></ha-esphome-setup-reminder>`
|
||||
: nothing
|
||||
}
|
||||
<ha-device-child-devices-card
|
||||
.hass=${this.hass}
|
||||
.deviceId=${this.deviceId}
|
||||
@@ -1115,6 +1206,20 @@ export class HaConfigDevicePage extends LitElement {
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
showESPHomeSetup && !esphomeDeferred
|
||||
? html`
|
||||
<ha-esphome-setup-banner
|
||||
class="fullwidth"
|
||||
.deviceName=${deviceName}
|
||||
.status=${esphomeStatus}
|
||||
.started=${esphomeStarted}
|
||||
@esphome-setup=${this._showESPHomeSetup}
|
||||
@esphome-setup-later=${this._deferESPHomeSetup}
|
||||
></ha-esphome-setup-banner>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${columnContents.map(
|
||||
(contents) => html`<div class="column">${contents}</div>`
|
||||
)}
|
||||
@@ -1129,6 +1234,105 @@ export class HaConfigDevicePage extends LitElement {
|
||||
clearTimeout(this._deviceAlertsActionsTimeout);
|
||||
this._getDeviceActions();
|
||||
this._getDeviceAlerts();
|
||||
this._fetchESPHomeCapabilities();
|
||||
}
|
||||
}
|
||||
|
||||
private async _subscribeESPHomeUserData() {
|
||||
const generation = this._esphomeUserDataSubGeneration;
|
||||
try {
|
||||
const unsub = await subscribeFrontendUserData(
|
||||
this.hass.connection,
|
||||
"esphome",
|
||||
({ value }) => {
|
||||
if (generation !== this._esphomeUserDataSubGeneration) {
|
||||
return;
|
||||
}
|
||||
this._esphomeUserData = value;
|
||||
this._esphomeUserDataReady = true;
|
||||
}
|
||||
);
|
||||
if (generation !== this._esphomeUserDataSubGeneration) {
|
||||
unsub();
|
||||
return;
|
||||
}
|
||||
this._unsubEsphomeUserData = unsub;
|
||||
} catch (_err) {
|
||||
if (generation !== this._esphomeUserDataSubGeneration) {
|
||||
return;
|
||||
}
|
||||
this._esphomeUserData = null;
|
||||
this._esphomeUserDataReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchESPHomeCapabilities() {
|
||||
const deviceId = this.deviceId;
|
||||
const device = this.hass.devices[deviceId];
|
||||
if (!device) {
|
||||
this._esphomeCapabilities = undefined;
|
||||
return;
|
||||
}
|
||||
const domains = this._integrations(
|
||||
device,
|
||||
this.entries,
|
||||
this.manifests
|
||||
).map((entry) => entry.domain);
|
||||
if (!domains.includes("esphome")) {
|
||||
this._esphomeCapabilities = undefined;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const capabilities = await fetchESPHomeDeviceCapabilities(
|
||||
this.hass,
|
||||
deviceId
|
||||
);
|
||||
if (this.deviceId !== deviceId) {
|
||||
return;
|
||||
}
|
||||
this._esphomeCapabilities = capabilities;
|
||||
} catch (_err) {
|
||||
if (this.deviceId !== deviceId) {
|
||||
return;
|
||||
}
|
||||
this._esphomeCapabilities = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _showESPHomeSetup = () => {
|
||||
const device = this.hass.devices[this.deviceId];
|
||||
showESPHomeDeviceSetupDialog(this, {
|
||||
deviceId: this.deviceId,
|
||||
deviceName: device
|
||||
? computeDeviceNameDisplay(device, this.hass.localize, this.hass.states)
|
||||
: undefined,
|
||||
capabilities: this._esphomeCapabilities,
|
||||
mediaPlayerSupported: deviceHasMediaPlayerEntity(
|
||||
this.deviceId,
|
||||
this._entities(this.deviceId, this._entityReg, this.hass.devices)
|
||||
),
|
||||
dialogClosedCallback: () => {
|
||||
this._fetchESPHomeCapabilities();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private async _deferESPHomeSetup() {
|
||||
const previous = this._esphomeUserData;
|
||||
const next = withDeferredESPHomeDevice(previous, this.deviceId);
|
||||
this._esphomeUserData = next;
|
||||
try {
|
||||
await saveFrontendUserData(this.hass.connection, "esphome", next);
|
||||
} catch (err: unknown) {
|
||||
this._esphomeUserData = previous;
|
||||
await showAlertDialog(this, {
|
||||
text:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.devices.esphome.setup_error_defer"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ const SEARCH_KEYS = [
|
||||
{ name: "search_labels.entityName", weight: 10 },
|
||||
{ name: "search_labels.friendlyName", weight: 9 },
|
||||
{ name: "search_labels.deviceName", weight: 8 },
|
||||
{ name: "search_labels.parentDeviceName", weight: 6 },
|
||||
{ name: "search_labels.areaName", weight: 6 },
|
||||
{ name: "search_labels.domainName", weight: 4 },
|
||||
{ name: "id", weight: 2 },
|
||||
@@ -65,20 +64,14 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
|
||||
if (stateObj) {
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
|
||||
@@ -96,7 +89,6 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
},
|
||||
|
||||
@@ -160,17 +160,11 @@ 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,
|
||||
parentDeviceName,
|
||||
deviceName,
|
||||
entityName,
|
||||
].filter((v): v is string => Boolean(v));
|
||||
const breadcrumb = [areaName, deviceName, entityName].filter(
|
||||
(v): v is string => Boolean(v)
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
|
||||
+1
@@ -306,6 +306,7 @@ class DialogZWaveJSAddNode extends LitElement {
|
||||
if (this._step === "choose_security_strategy") {
|
||||
return html`<zwave-js-add-node-select-security-strategy
|
||||
.hass=${this.hass}
|
||||
.inclusionStrategy=${this._inclusionStrategy}
|
||||
@z-wave-strategy-selected=${this._setSecurityStrategy}
|
||||
></zwave-js-add-node-select-security-strategy>`;
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import memoizeOne from "memoize-one";
|
||||
|
||||
@@ -14,7 +14,7 @@ import "../../../../../../components/ha-form/ha-form";
|
||||
export class ZWaveJsAddNodeSelectMethod extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() public _inclusionStrategy?: InclusionStrategy;
|
||||
@property({ attribute: false }) public inclusionStrategy?: InclusionStrategy;
|
||||
|
||||
private _getSchema = memoizeOne((localize: LocalizeFunc): HaFormSchema[] => [
|
||||
{
|
||||
@@ -62,7 +62,7 @@ export class ZWaveJsAddNodeSelectMethod extends LitElement {
|
||||
return html`
|
||||
<ha-form
|
||||
.schema=${this._getSchema(this.hass.localize)}
|
||||
.data=${{ strategy: this._inclusionStrategy?.toString() }}
|
||||
.data=${{ strategy: this.inclusionStrategy?.toString() }}
|
||||
@value-changed=${this._selectStrategy}
|
||||
.computeLabel=${this._computeLabel}
|
||||
>
|
||||
|
||||
@@ -195,12 +195,7 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
|
||||
const nameList = computeEntityNameList(
|
||||
entity,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this._registries.entities,
|
||||
this._registries.devices,
|
||||
this._registries.areas,
|
||||
@@ -223,18 +218,13 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, deviceName, parentDeviceName, areaName] = nameList;
|
||||
const [, deviceName, 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;
|
||||
@@ -247,18 +237,14 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
|
||||
private _renderItem = (item: FilteredEntity) => {
|
||||
const { entity: entityState, nameList } = item;
|
||||
const [entityName, deviceName, parentDeviceName, areaName] = nameList;
|
||||
const [entityName, deviceName, areaName] = nameList;
|
||||
|
||||
const isRTL = computeRTL(
|
||||
this._i18n.language,
|
||||
this._i18n.translationMetadata.translations
|
||||
);
|
||||
const primary = entityName || deviceName || entityState.entity_id;
|
||||
const context = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
const context = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
const showEntityId = this._config?.userData?.showEntityIdPicker;
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
countTargets,
|
||||
} from "../../components/ha-sources-picker";
|
||||
import type { SourceFilters } from "../../components/ha-sources-picker";
|
||||
import { entityTypesNeedStates } from "../../data/entity/entity_type";
|
||||
import "../../components/ha-spinner";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import type { EntitySources } from "../../data/entity/entity_sources";
|
||||
@@ -216,6 +217,7 @@ class HaPanelHistory extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.value=${this._targetPickerValue}
|
||||
.filters=${this._filters}
|
||||
.entitySources=${this._entitySources}
|
||||
.disabled=${this._isLoading}
|
||||
.description=${this.hass.localize(
|
||||
"ui.panel.history.no_targets"
|
||||
@@ -536,8 +538,10 @@ class HaPanelHistory extends LitElement {
|
||||
this.hass.areas
|
||||
),
|
||||
this._filters,
|
||||
// Only the device class filter reads the states.
|
||||
this._filters.deviceClasses?.length ? this.hass.states : EMPTY_STATES,
|
||||
// Only a device class narrows down using the states.
|
||||
entityTypesNeedStates(this._filters.types)
|
||||
? this.hass.states
|
||||
: EMPTY_STATES,
|
||||
this.hass.entities,
|
||||
this._entitySources
|
||||
);
|
||||
@@ -732,6 +736,10 @@ class HaPanelHistory extends LitElement {
|
||||
haStyle,
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
/* The target picker chips need more room than a plain filter list. */
|
||||
--ha-filter-pane-width: 340px;
|
||||
}
|
||||
ha-top-app-bar-fixed {
|
||||
height: 100vh;
|
||||
overflow-x: hidden;
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
countTargets,
|
||||
} from "../../components/ha-sources-picker";
|
||||
import type { SourceFilters } from "../../components/ha-sources-picker";
|
||||
import { entityTypesNeedStates } from "../../data/entity/entity_type";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import type { EntitySources } from "../../data/entity/entity_sources";
|
||||
@@ -163,6 +164,7 @@ export class HaPanelLogbook extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.value=${this._targetPickerValue}
|
||||
.filters=${this._filters}
|
||||
.entitySources=${this._entitySources}
|
||||
.entityFilter=${this._filterFunc}
|
||||
.description=${this.hass.localize(
|
||||
"ui.panel.logbook.no_targets"
|
||||
@@ -331,8 +333,10 @@ export class HaPanelLogbook extends LitElement {
|
||||
return this.__filterEntityIds(
|
||||
targetEntities ?? this.__logbookEntityIds(this.hass.states),
|
||||
this._filters,
|
||||
// Only the device class filter reads the states.
|
||||
this._filters.deviceClasses?.length ? this.hass.states : EMPTY_STATES,
|
||||
// Only a device class narrows down using the states.
|
||||
entityTypesNeedStates(this._filters.types)
|
||||
? this.hass.states
|
||||
: EMPTY_STATES,
|
||||
this.hass.entities,
|
||||
this._entitySources
|
||||
);
|
||||
@@ -579,6 +583,8 @@ export class HaPanelLogbook extends LitElement {
|
||||
:host {
|
||||
--ha-generic-picker-width: min(400px, calc(100vw - 32px));
|
||||
--ha-generic-picker-max-width: 400px;
|
||||
/* The target picker chips need more room than a plain filter list. */
|
||||
--ha-filter-pane-width: 340px;
|
||||
}
|
||||
|
||||
.content {
|
||||
|
||||
@@ -194,6 +194,7 @@ export class HuiEnergyDevicesGraphCard
|
||||
this._legendData
|
||||
)}
|
||||
.height=${`${Math.max(modes.includes("pie") ? 300 : 100, (this._legendData?.length || 0) * 28 + 50)}px`}
|
||||
.sonificationLabelFormatter=${this._sonificationLabel}
|
||||
.extraComponents=${[PieChart]}
|
||||
.expandLegend=${this._config.expand_legend}
|
||||
click-label-for-more-info
|
||||
@@ -293,6 +294,14 @@ export class HuiEnergyDevicesGraphCard
|
||||
}
|
||||
);
|
||||
|
||||
// The chart data is keyed on statistic ids, which is what Chart2Music would
|
||||
// otherwise announce. Names that aren't statistics — the untracked slice —
|
||||
// are already display text, so those stay as they are.
|
||||
private _sonificationLabel = (label: string): string | undefined =>
|
||||
this._deviceLabels[label] || this._data?.statsMetadata[label]
|
||||
? this._getDeviceName(label)
|
||||
: undefined;
|
||||
|
||||
private _getDeviceName(statisticId: string): string {
|
||||
const suffix = this._compoundStats.includes(statisticId)
|
||||
? ` (${this.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_graph.untracked")})`
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { computeCssColor } from "../../../common/color/compute-color";
|
||||
import { consumeEntityState } from "../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { computeStateName } from "../../../common/entity/compute_state_name";
|
||||
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-relative-time";
|
||||
import "../../../components/ha-state-icon";
|
||||
import "../../../components/tile/ha-tile-container";
|
||||
import "../../../components/tile/ha-tile-icon";
|
||||
import "../../../components/tile/ha-tile-info";
|
||||
import { formattersContext } from "../../../data/context";
|
||||
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||
import type { LovelaceCard, LovelaceGridOptions } from "../types";
|
||||
import { tileCardStyle } from "./tile/tile-card-style";
|
||||
import type { AlertCardConfig } from "./types";
|
||||
|
||||
@customElement("hui-alert-card")
|
||||
export class HuiAlertCard extends LitElement implements LovelaceCard {
|
||||
@state() private _config?: AlertCardConfig;
|
||||
|
||||
@state()
|
||||
@consumeEntityState({ entityIdPath: ["_config", "entity"] })
|
||||
private _stateObj?: HassEntity;
|
||||
|
||||
@state()
|
||||
@consume({ context: formattersContext, subscribe: true })
|
||||
private _formatters!: ContextType<typeof formattersContext>;
|
||||
|
||||
public setConfig(config: AlertCardConfig): void {
|
||||
if (
|
||||
!isValidEntityId(config.entity) ||
|
||||
(config.color !== undefined && typeof config.color !== "string") ||
|
||||
(config.pulse !== undefined && typeof config.pulse !== "boolean")
|
||||
) {
|
||||
throw new Error("Invalid configuration");
|
||||
}
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
public getCardSize(): number {
|
||||
return 1;
|
||||
}
|
||||
|
||||
public getGridOptions(): LovelaceGridOptions {
|
||||
return {
|
||||
columns: 6,
|
||||
rows: 1,
|
||||
min_columns: 6,
|
||||
min_rows: 1,
|
||||
};
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._config || !this._formatters) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const stateObj = this._stateObj;
|
||||
if (!stateObj) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const stateDisplay = this._formatters.formatEntityState(stateObj);
|
||||
const areaName = this._formatters.formatEntityName(stateObj, {
|
||||
type: "area",
|
||||
});
|
||||
const pulse = this._config.pulse === true;
|
||||
const hasColor =
|
||||
this._config.color !== undefined && this._config.color !== "none";
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
class=${classMap({ pulse, "no-color": !hasColor })}
|
||||
style=${styleMap({
|
||||
"--ha-alert-color":
|
||||
this._config.color && hasColor
|
||||
? computeCssColor(this._config.color)
|
||||
: undefined,
|
||||
"--ha-alert-static-opacity": pulse
|
||||
? undefined
|
||||
: "var(--ha-alert-pulse-opacity)",
|
||||
})}
|
||||
>
|
||||
<ha-tile-container
|
||||
.interactive=${true}
|
||||
.actionHandlerOptions=${{ hasHold: false, hasDoubleClick: false }}
|
||||
@action=${this._handleAction}
|
||||
>
|
||||
<ha-tile-icon slot="icon">
|
||||
<ha-state-icon slot="icon" .stateObj=${stateObj}></ha-state-icon>
|
||||
</ha-tile-icon>
|
||||
<ha-tile-info slot="info">
|
||||
<span slot="primary">${computeStateName(stateObj)}</span>
|
||||
<span slot="secondary">
|
||||
${stateDisplay} ·
|
||||
<ha-relative-time
|
||||
.datetime=${stateObj.last_changed}
|
||||
.format=${"short"}
|
||||
></ha-relative-time>
|
||||
${areaName ? html` · ${areaName}` : nothing}
|
||||
</span>
|
||||
</ha-tile-info>
|
||||
</ha-tile-container>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleAction(ev: ActionHandlerEvent): void {
|
||||
if (ev.detail.action === "tap" && this._config) {
|
||||
fireEvent(this, "hass-more-info", { entityId: this._config.entity });
|
||||
}
|
||||
}
|
||||
|
||||
static styles = [
|
||||
tileCardStyle,
|
||||
css`
|
||||
@keyframes pulse-opacity {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: var(--ha-pulse-opacity, 0.3);
|
||||
}
|
||||
}
|
||||
:host {
|
||||
display: block;
|
||||
--ha-alert-pulse-duration: 1s;
|
||||
--ha-alert-pulse-opacity: 0.3;
|
||||
--ha-alert-static-opacity: 0;
|
||||
}
|
||||
ha-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
ha-card:not(.no-color) {
|
||||
--tile-color: var(--ha-alert-color);
|
||||
}
|
||||
ha-card.no-color {
|
||||
--tile-color: var(--secondary-text-color);
|
||||
}
|
||||
ha-card::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
|
||||
background-color: var(--ha-alert-color);
|
||||
content: "";
|
||||
opacity: var(--ha-alert-static-opacity);
|
||||
pointer-events: none;
|
||||
}
|
||||
ha-card.pulse::before {
|
||||
--ha-pulse-opacity: var(--ha-alert-pulse-opacity);
|
||||
animation: pulse-opacity var(--ha-alert-pulse-duration) ease-in-out
|
||||
infinite alternate;
|
||||
}
|
||||
ha-card:not(.pulse)::before {
|
||||
animation: none;
|
||||
}
|
||||
ha-tile-container {
|
||||
position: relative;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
ha-card::before {
|
||||
animation: none;
|
||||
opacity: var(--ha-alert-pulse-opacity);
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-alert-card": HuiAlertCard;
|
||||
}
|
||||
}
|
||||
@@ -718,6 +718,13 @@ export interface ShortcutCardConfig extends LovelaceCardConfig {
|
||||
double_tap_action?: ActionConfig;
|
||||
}
|
||||
|
||||
export interface AlertCardConfig extends LovelaceCardConfig {
|
||||
type: "alert";
|
||||
entity: string;
|
||||
color?: string;
|
||||
pulse?: boolean;
|
||||
}
|
||||
|
||||
export interface ToggleGroupCardConfig extends LovelaceCardConfig {
|
||||
title: string;
|
||||
entities: string[];
|
||||
|
||||
@@ -63,8 +63,8 @@ export class HuiEntityEditor extends LitElement {
|
||||
this.hass.formatEntityName(
|
||||
stateObj,
|
||||
useDeviceName
|
||||
? [{ type: "area" }, { type: "parent_device" }]
|
||||
: [{ type: "area" }, { type: "parent_device" }, { type: "device" }],
|
||||
? [{ type: "area" }]
|
||||
: [{ type: "area" }, { type: "device" }],
|
||||
{
|
||||
separator: isRTL ? " ◂ " : " ▸ ",
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ const LAZY_LOAD_TYPES = {
|
||||
shortcut: () => import("../cards/hui-shortcut-card"),
|
||||
"discovered-devices": () => import("../cards/hui-discovered-devices-card"),
|
||||
repairs: () => import("../cards/hui-repairs-card"),
|
||||
alert: () => import("../cards/hui-alert-card"),
|
||||
updates: () => import("../cards/hui-updates-card"),
|
||||
gauge: () => import("../cards/hui-gauge-card"),
|
||||
"history-graph": () => import("../cards/hui-history-graph-card"),
|
||||
|
||||
@@ -32,7 +32,6 @@ interface EntityPickerTableRowData extends DataTableRowData {
|
||||
name: string;
|
||||
entity_name?: string;
|
||||
device_name?: string;
|
||||
parent_device_name?: string;
|
||||
area_name?: string;
|
||||
domain_name: string;
|
||||
last_changed: string;
|
||||
@@ -61,20 +60,14 @@ export class HuiEntityPickerTable extends LitElement {
|
||||
(entity) => {
|
||||
const stateObj = this.hass.states[entity];
|
||||
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const name = [deviceName, entityName].filter(Boolean).join(" ");
|
||||
const domain = computeDomain(entity);
|
||||
|
||||
@@ -85,7 +78,6 @@ export class HuiEntityPickerTable extends LitElement {
|
||||
name: name,
|
||||
entity_name: entityName,
|
||||
device_name: deviceName,
|
||||
parent_device_name: parentDeviceName,
|
||||
area_name: areaName,
|
||||
domain_name: domainToName(localize, domain),
|
||||
last_changed: stateObj!.last_changed,
|
||||
@@ -160,7 +152,6 @@ export class HuiEntityPickerTable extends LitElement {
|
||||
entity.entity_name || entity.device_name || entity.entity_id;
|
||||
const secondary = [
|
||||
entity.area_name,
|
||||
entity.parent_device_name,
|
||||
entity.entity_name ? entity.device_name : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -200,12 +191,6 @@ export class HuiEntityPickerTable extends LitElement {
|
||||
hidden: true,
|
||||
};
|
||||
|
||||
columns.parent_device_name = {
|
||||
title: "parent_device_name",
|
||||
filterable: true,
|
||||
hidden: true,
|
||||
};
|
||||
|
||||
columns.area_name = {
|
||||
title: "area_name",
|
||||
filterable: true,
|
||||
|
||||
@@ -26,7 +26,7 @@ import type {
|
||||
SchemaUnion,
|
||||
} from "../../../../components/ha-form/types";
|
||||
import type { SelectOption } from "../../../../data/selector";
|
||||
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "../../../../data/sensor_numeric_device_classes";
|
||||
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "../../../../data/sensor_entity_constants";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type {
|
||||
LovelaceCardFeatureConfig,
|
||||
|
||||
@@ -172,20 +172,14 @@ export class HuiHeadingBadgesEditor extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(
|
||||
this.hass.language,
|
||||
@@ -193,11 +187,7 @@ export class HuiHeadingBadgesEditor extends LitElement {
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [
|
||||
entityName ? deviceName : undefined,
|
||||
parentDeviceName,
|
||||
areaName,
|
||||
]
|
||||
const secondary = [entityName ? deviceName : undefined, areaName]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { array, enums, literal, object, string, union } from "superstruct";
|
||||
import { ENTITY_NAME_TYPES } from "../../../../common/entity/compute_entity_name_display";
|
||||
import { array, literal, object, string, union } from "superstruct";
|
||||
|
||||
const entityNameItemStruct = union([
|
||||
object({
|
||||
@@ -7,7 +6,12 @@ const entityNameItemStruct = union([
|
||||
text: string(),
|
||||
}),
|
||||
object({
|
||||
type: enums(ENTITY_NAME_TYPES),
|
||||
type: union([
|
||||
literal("entity"),
|
||||
literal("device"),
|
||||
literal("area"),
|
||||
literal("floor"),
|
||||
]),
|
||||
}),
|
||||
string(),
|
||||
]);
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
|
||||
import type { ShortcutItem } from "../../../../data/home_shortcuts";
|
||||
import { resolveShortcutItems } from "../../../../data/home_shortcuts";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { hasClimateEntities } from "../../../climate/strategies/climate-view-strategy";
|
||||
import type {
|
||||
AreaCardConfig,
|
||||
DiscoveredDevicesCardConfig,
|
||||
@@ -291,10 +292,6 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
generateEntityFilter(hass, filter)
|
||||
);
|
||||
|
||||
const climateFilters = HOME_SUMMARIES_FILTERS.climate.map((filter) =>
|
||||
generateEntityFilter(hass, filter)
|
||||
);
|
||||
|
||||
const securityFilters = HOME_SUMMARIES_FILTERS.security.map((filter) =>
|
||||
generateEntityFilter(hass, filter)
|
||||
);
|
||||
@@ -307,9 +304,7 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
hass.panels.light && findEntities(allEntities, lightsFilters).length > 0;
|
||||
const hasMediaPlayers =
|
||||
findEntities(allEntities, mediaPlayerFilter).length > 0;
|
||||
const hasClimate =
|
||||
hass.panels.climate &&
|
||||
findEntities(allEntities, climateFilters).length > 0;
|
||||
const hasClimate = hass.panels.climate && hasClimateEntities(hass);
|
||||
const hasSecurity =
|
||||
hass.panels.security &&
|
||||
findEntities(allEntities, securityFilters).length > 0;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { join } from "lit/directives/join";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import type { EntityNameType } from "../common/entity/compute_entity_name_display";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
import {
|
||||
STRINGS_SEPARATOR_DOT,
|
||||
@@ -57,13 +56,6 @@ export const DEFAULT_STATE_CONTENT_DOMAINS: Record<string, StateContent> = {
|
||||
valve: ["state", "current_position"],
|
||||
};
|
||||
|
||||
const NAME_CONTENT_TYPES: Partial<Record<string, EntityNameType>> = {
|
||||
device_name: "device",
|
||||
parent_device_name: "parent_device",
|
||||
area_name: "area",
|
||||
floor_name: "floor",
|
||||
};
|
||||
|
||||
const TIMESTAMP_STATE_PROPS = ["last_updated", "last_changed"];
|
||||
|
||||
const TIMESTAMP_CONTENTS = [...TIMESTAMP_STATE_PROPS, "last_triggered"];
|
||||
@@ -198,11 +190,13 @@ class StateDisplay extends LitElement {
|
||||
if (content === "entity-id") {
|
||||
return stateObj.entity_id;
|
||||
}
|
||||
const nameType = NAME_CONTENT_TYPES[content];
|
||||
if (nameType) {
|
||||
return (
|
||||
this.hass.formatEntityName(stateObj, { type: nameType }) || undefined
|
||||
);
|
||||
if (
|
||||
content === "device_name" ||
|
||||
content === "area_name" ||
|
||||
content === "floor_name"
|
||||
) {
|
||||
const type = content.replace("_name", "") as "device" | "area" | "floor";
|
||||
return this.hass.formatEntityName(stateObj, { type }) || undefined;
|
||||
}
|
||||
|
||||
let relativeDateTime: string | number | undefined;
|
||||
|
||||
@@ -764,12 +764,10 @@
|
||||
"types": {
|
||||
"floor": "Floor",
|
||||
"area": "Area",
|
||||
"parent_device": "[%key:ui::components::device-picker::parent_device%]",
|
||||
"device": "Device",
|
||||
"entity": "Entity",
|
||||
"area_missing": "No area assigned",
|
||||
"floor_missing": "No floor assigned",
|
||||
"parent_device_missing": "No parent device",
|
||||
"device_missing": "No related device"
|
||||
},
|
||||
"mode_composed": "Composed",
|
||||
@@ -808,6 +806,7 @@
|
||||
"replace_device": "Replace",
|
||||
"devices_count": "{count} {count, plural,\n one {device}\n other {devices}\n}",
|
||||
"entities_count": "{count} {count, plural,\n one {entity}\n other {entities}\n}",
|
||||
"entities_count_filtered": "{count}/{total} {total, plural,\n one {entity}\n other {entities}\n}",
|
||||
"target_details": "Target details",
|
||||
"no_targets": "No targets",
|
||||
"no_target_found": "No target found for {term}",
|
||||
@@ -837,8 +836,11 @@
|
||||
},
|
||||
"style": "Time format style"
|
||||
},
|
||||
"filter-device-classes": {
|
||||
"caption": "Device class"
|
||||
"filter-entity-types": {
|
||||
"caption": "Type",
|
||||
"no_device_class": "No type",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse"
|
||||
},
|
||||
"subpage-data-table": {
|
||||
"filters": "Filters",
|
||||
@@ -918,7 +920,6 @@
|
||||
"no_devices": "No devices available",
|
||||
"no_match": "No devices found for {term}",
|
||||
"device": "Device",
|
||||
"parent_device": "Parent device",
|
||||
"unnamed_device": "Unnamed device",
|
||||
"no_area": "No area",
|
||||
"placeholder": "Select a device",
|
||||
@@ -1449,7 +1450,6 @@
|
||||
"remaining_time": "Remaining time",
|
||||
"install_status": "Install status",
|
||||
"device_name": "Device name",
|
||||
"parent_device_name": "Parent device name",
|
||||
"area_name": "Area name",
|
||||
"floor_name": "Floor name"
|
||||
},
|
||||
@@ -1677,7 +1677,6 @@
|
||||
"context": "Context",
|
||||
"entity": "Entity",
|
||||
"floor": "Floor",
|
||||
"parent_device": "[%key:ui::components::device-picker::parent_device%]",
|
||||
"entity_id": "Entity ID",
|
||||
"labels": "Labels",
|
||||
"toggle_yaml_mode": "Toggle YAML mode",
|
||||
@@ -6957,7 +6956,62 @@
|
||||
"esphome": {
|
||||
"show_encryption_key": "Show encryption key",
|
||||
"encryption_key_title": "ESPHome Encryption Key",
|
||||
"encryption_key_description": "This is the encryption key for your ESPHome device. Keep it in a safe place, as you may need it when transferring devices between Home Assistant instances."
|
||||
"encryption_key_description": "This is the encryption key for your ESPHome device. Keep it in a safe place, as you may need it when transferring devices between Home Assistant instances.",
|
||||
"setup_title": "Set up your ESPHome proxy",
|
||||
"setup_continue_title": "Continue setup for {name}",
|
||||
"setup_intro": "This adapter unlocks {count} simultaneous {count, plural,\n one {capability}\n other {capabilities}\n} in Home Assistant. Set them up now, or come back to this checklist any time.",
|
||||
"setup_action": "Set up",
|
||||
"setup_later": "Later",
|
||||
"setup_learn_more": "Learn more",
|
||||
"setup_reminder_remaining": "Set up {count} more {count, plural,\n one {capability}\n other {capabilities}\n}",
|
||||
"setup_reminder_done": "Everything is set up",
|
||||
"setup_reminder_intro": "This adapter unlocks {count} simultaneous {count, plural,\n one {capability}\n other {capabilities}\n} in Home Assistant.",
|
||||
"setup_reminder_done_intro": "All {count} simultaneous {count, plural,\n one {capability is}\n other {capabilities are}\n} configured.",
|
||||
"setup_capability_bluetooth_title": "Bluetooth proxy",
|
||||
"setup_capability_bluetooth_short": "Extend Bluetooth range",
|
||||
"setup_capability_bluetooth_description": "Add nearby Bluetooth devices into Home Assistant. Adding multiple Bluetooth proxies allows for better connectivity with devices across your home.",
|
||||
"setup_capability_audio_title": "Stream audio via Sendspin",
|
||||
"setup_capability_audio_short": "Multi-room sound, recommended with Music Assistant",
|
||||
"setup_capability_audio_description": "Stream audio from Home Assistant (including announcements, media, or radio); just note this audio will be unsynchronized with other players. For synchronized whole-home audio, use Music Assistant.",
|
||||
"setup_capability_connectivity_title": "Connect a Zigbee or Z-Wave adapter",
|
||||
"setup_capability_connectivity_short": "Network connectivity for ZBT-2 or ZWA-2",
|
||||
"setup_capability_connectivity_description": "Plug a Connect ZBT-2 or ZWA-2 into the USB port to enable Zigbee or Z-Wave connectivity over Ethernet or Wi-Fi. Get your antenna in an optimal location for better device responsiveness.",
|
||||
"setup_capability_serial_title": "Control a device over serial",
|
||||
"setup_capability_serial_short": "AV receivers, amplifiers, and more",
|
||||
"setup_capability_serial_description": "Add serial devices into Home Assistant, allowing for control of A/V receivers and multi-zone amplifiers.",
|
||||
"setup_audio_home_assistant": "Home Assistant",
|
||||
"setup_audio_builtin_playback": "Built-in playback",
|
||||
"setup_audio_music_assistant": "Music Assistant",
|
||||
"setup_audio_music_assistant_meta": "Synchronized multi-room audio",
|
||||
"setup_audio_music_assistant_upsell": "The better way to stream with a free app",
|
||||
"setup_audio_benefit_multiroom": "Synchronized multi-room playback",
|
||||
"setup_audio_benefit_lossless": "Lossless, high-resolution audio",
|
||||
"setup_audio_benefit_album_art": "Album art on every screen",
|
||||
"setup_audio_active": "Active",
|
||||
"setup_audio_recommended": "Recommended",
|
||||
"setup_install_music_assistant": "Install Music Assistant",
|
||||
"setup_open_music_assistant": "Open Music Assistant",
|
||||
"setup_installing_music_assistant": "Installing Music Assistant",
|
||||
"setup_starting_music_assistant": "Starting Music Assistant",
|
||||
"setup_discovering_music_assistant": "Waiting for Music Assistant discovery",
|
||||
"setup_error_music_assistant": "Couldn't install Music Assistant",
|
||||
"setup_error_capabilities": "Couldn't load device capabilities",
|
||||
"setup_error_defer": "Couldn't save setup for later",
|
||||
"setup_error_serial_integration": "Couldn't start this integration",
|
||||
"setup_status_completed": "Completed",
|
||||
"setup_status_active": "Active",
|
||||
"setup_zwave": "Set up Z-Wave",
|
||||
"setup_what_are_adapters": "What are these adapters?",
|
||||
"setup_adapters_title": "Add Zigbee or Z-Wave",
|
||||
"setup_adapters_intro": "Plug a Home Assistant Connect adapter into your proxy's USB port to add wireless radios:",
|
||||
"setup_adapter_zbt2": "Connect ZBT-2",
|
||||
"setup_adapter_zbt2_description": "Adds a Zigbee radio.",
|
||||
"setup_adapter_zwa2": "Connect ZWA-2",
|
||||
"setup_adapter_zwa2_description": "Adds a Z-Wave radio.",
|
||||
"setup_serial_choose": "Choose an integration",
|
||||
"setup_serial_intro": "Choose an integration to set up a serial device on this ESPHome device. The integration will let you pick the serial port.",
|
||||
"setup_serial_ports": "Ports on this device",
|
||||
"setup_serial_integrations": "Serial integrations"
|
||||
}
|
||||
},
|
||||
"entities": {
|
||||
|
||||
@@ -245,43 +245,6 @@ describe("computeEntityNameDisplay", () => {
|
||||
expect(result).toBe("Kitchen Smart Light");
|
||||
});
|
||||
|
||||
it("returns parent device name for an entity on a child device", () => {
|
||||
const stateObj = mockStateObj({ entity_id: "switch.outlet_1" });
|
||||
const hass = {
|
||||
entities: {
|
||||
"switch.outlet_1": mockEntity({
|
||||
entity_id: "switch.outlet_1",
|
||||
name: "Switch",
|
||||
device_id: "child_1",
|
||||
}),
|
||||
},
|
||||
devices: {
|
||||
child_1: mockDevice({
|
||||
id: "child_1",
|
||||
name: "Outlet 1",
|
||||
parent_device_id: "parent_1",
|
||||
}),
|
||||
parent_1: mockDevice({
|
||||
id: "parent_1",
|
||||
name: "Power strip",
|
||||
}),
|
||||
},
|
||||
areas: {},
|
||||
floors: {},
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
const result = computeEntityNameDisplay(
|
||||
stateObj,
|
||||
[{ type: "parent_device" }, { type: "device" }, { type: "entity" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
expect(result).toBe("Power strip Outlet 1 Switch");
|
||||
});
|
||||
|
||||
it("returns floor name", () => {
|
||||
const stateObj = mockStateObj({ entity_id: "light.kitchen" });
|
||||
const hass = {
|
||||
|
||||
@@ -37,7 +37,6 @@ describe("getEntityContext", () => {
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device: null,
|
||||
parentDevice: null,
|
||||
area: null,
|
||||
floor: null,
|
||||
});
|
||||
@@ -89,7 +88,6 @@ describe("getEntityContext", () => {
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device,
|
||||
parentDevice: null,
|
||||
area,
|
||||
floor,
|
||||
});
|
||||
@@ -146,7 +144,6 @@ describe("getEntityContext", () => {
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device: childDevice,
|
||||
parentDevice,
|
||||
area,
|
||||
floor,
|
||||
});
|
||||
@@ -187,7 +184,6 @@ describe("getEntityContext", () => {
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device: null,
|
||||
parentDevice: null,
|
||||
area,
|
||||
floor,
|
||||
});
|
||||
@@ -227,38 +223,8 @@ describe("getEntityContext", () => {
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device: null,
|
||||
parentDevice: null,
|
||||
area,
|
||||
floor: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not resolve a parent device for an entity on a main device", () => {
|
||||
const entity = mockEntity({
|
||||
entity_id: "switch.strip",
|
||||
device_id: "parent_1",
|
||||
});
|
||||
const parentDevice = mockDevice({
|
||||
id: "parent_1",
|
||||
});
|
||||
const stateObj = mockStateObj({
|
||||
entity_id: "switch.strip",
|
||||
});
|
||||
|
||||
const result = getEntityContext(
|
||||
stateObj,
|
||||
{ "switch.strip": entity },
|
||||
{ parent_1: parentDevice },
|
||||
{},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device: parentDevice,
|
||||
parentDevice: null,
|
||||
area: null,
|
||||
floor: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { timeCachePromiseFunc } from "../../../src/common/util/time-cache-function-promise";
|
||||
|
||||
describe("timeCachePromiseFunc", () => {
|
||||
it("reuses an in-flight request when the cache key is unchanged", async () => {
|
||||
let resolveRequest!: (value: string) => void;
|
||||
|
||||
const request = new Promise<string>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
|
||||
const fetchData = vi.fn(() => request);
|
||||
const hass = { version: 1 };
|
||||
|
||||
const calls = Array.from({ length: 20 }, () =>
|
||||
timeCachePromiseFunc(
|
||||
"_testCache",
|
||||
30_000,
|
||||
fetchData,
|
||||
(currentHass: typeof hass) => currentHass.version,
|
||||
hass
|
||||
)
|
||||
);
|
||||
|
||||
expect(fetchData).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveRequest("result");
|
||||
|
||||
await expect(Promise.all(calls)).resolves.toEqual(Array(20).fill("result"));
|
||||
|
||||
expect(fetchData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reuses a resolved cached result while the cache key is unchanged", async () => {
|
||||
const fetchData = vi.fn().mockResolvedValue("result");
|
||||
const hass = { version: 1 };
|
||||
|
||||
const first = await timeCachePromiseFunc(
|
||||
"_testCache",
|
||||
30_000,
|
||||
fetchData,
|
||||
(currentHass: typeof hass) => currentHass.version,
|
||||
hass
|
||||
);
|
||||
|
||||
const second = await timeCachePromiseFunc(
|
||||
"_testCache",
|
||||
30_000,
|
||||
fetchData,
|
||||
(currentHass: typeof hass) => currentHass.version,
|
||||
hass
|
||||
);
|
||||
|
||||
expect(first).toBe("result");
|
||||
expect(second).toBe("result");
|
||||
expect(fetchData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refetches a resolved cached result when the cache key changes", async () => {
|
||||
const fetchData = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce("first")
|
||||
.mockResolvedValueOnce("second");
|
||||
|
||||
const hass = { version: 1 };
|
||||
|
||||
const first = await timeCachePromiseFunc(
|
||||
"_testCache",
|
||||
30_000,
|
||||
fetchData,
|
||||
(currentHass: typeof hass) => currentHass.version,
|
||||
hass
|
||||
);
|
||||
|
||||
hass.version = 2;
|
||||
|
||||
const second = await timeCachePromiseFunc(
|
||||
"_testCache",
|
||||
30_000,
|
||||
fetchData,
|
||||
(currentHass: typeof hass) => currentHass.version,
|
||||
hass
|
||||
);
|
||||
|
||||
expect(first).toBe("first");
|
||||
expect(second).toBe("second");
|
||||
expect(fetchData).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reuses an in-flight request without a cache key validator", async () => {
|
||||
let resolveRequest!: (value: string) => void;
|
||||
|
||||
const request = new Promise<string>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
|
||||
const fetchData = vi.fn(() => request);
|
||||
const hass = {};
|
||||
|
||||
const first = timeCachePromiseFunc(
|
||||
"_testCacheNoValidator",
|
||||
30_000,
|
||||
fetchData,
|
||||
undefined,
|
||||
hass
|
||||
);
|
||||
|
||||
const second = timeCachePromiseFunc(
|
||||
"_testCacheNoValidator",
|
||||
30_000,
|
||||
fetchData,
|
||||
undefined,
|
||||
hass
|
||||
);
|
||||
|
||||
expect(fetchData).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveRequest("result");
|
||||
|
||||
await expect(first).resolves.toBe("result");
|
||||
await expect(second).resolves.toBe("result");
|
||||
expect(fetchData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeCachePromiseFunc cache ownership", () => {
|
||||
it("does not let an older cache timer clear a newer cached result", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const fetchData = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce("first")
|
||||
.mockResolvedValueOnce("second")
|
||||
.mockResolvedValueOnce("third");
|
||||
|
||||
const hass = { version: 1 };
|
||||
|
||||
await timeCachePromiseFunc(
|
||||
"_testTimerOwnership",
|
||||
1000,
|
||||
fetchData,
|
||||
(currentHass: typeof hass) => currentHass.version,
|
||||
hass
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
hass.version = 2;
|
||||
|
||||
await timeCachePromiseFunc(
|
||||
"_testTimerOwnership",
|
||||
1000,
|
||||
fetchData,
|
||||
(currentHass: typeof hass) => currentHass.version,
|
||||
hass
|
||||
);
|
||||
|
||||
expect(fetchData).toHaveBeenCalledTimes(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
const result = await timeCachePromiseFunc(
|
||||
"_testTimerOwnership",
|
||||
1000,
|
||||
fetchData,
|
||||
(currentHass: typeof hass) => currentHass.version,
|
||||
hass
|
||||
);
|
||||
|
||||
expect(result).toBe("second");
|
||||
expect(fetchData).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canSonifyChart } from "../../../src/components/chart/chart-sonification";
|
||||
import type { EChartsType } from "echarts/core";
|
||||
import { connect } from "echarts-extension-chart2music";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
canSonifyChart,
|
||||
sonifyChart,
|
||||
} from "../../../src/components/chart/chart-sonification";
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { FrontendLocaleData } from "../../../src/data/translation";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import type { HaECSeries } from "../../../src/resources/echarts/echarts";
|
||||
|
||||
vi.mock("echarts-extension-chart2music", () => ({
|
||||
connect: vi.fn((_chart, options) => {
|
||||
options.cc.setAttribute("aria-live", "assertive");
|
||||
return { update: vi.fn(), dispose: vi.fn() };
|
||||
}),
|
||||
}));
|
||||
|
||||
const series = (
|
||||
items: { type: string; id?: string; name?: string; data?: unknown[] }[]
|
||||
) => items as unknown as HaECSeries;
|
||||
@@ -95,9 +110,9 @@ describe("canSonifyChart", () => {
|
||||
expect(canSonifyChart(series([{ type: "line" }]))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects value-first pairs, which the extension reads as a non-numeric y", () => {
|
||||
// The energy device charts encode [amount, categoryName]. Chart2Music takes
|
||||
// value as [x, y], so every one of those points is dropped.
|
||||
it("accepts value-first pairs, like the energy device charts", () => {
|
||||
// The energy device charts encode [amount, categoryName]. Since v0.1.1 the
|
||||
// extension reads a series that way when every item has that shape.
|
||||
expect(
|
||||
canSonifyChart(
|
||||
series([
|
||||
@@ -110,18 +125,77 @@ describe("canSonifyChart", () => {
|
||||
},
|
||||
])
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
canSonifyChart(
|
||||
series([
|
||||
{
|
||||
type: "bar",
|
||||
data: [
|
||||
[12.5, "sensor.a"],
|
||||
[8.25, "sensor.b"],
|
||||
],
|
||||
},
|
||||
])
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not read empty markers or numeric strings as value-first pairs", () => {
|
||||
// [2, "-"] is an ECharts gap inside an ordinary series and [1, "8"] is a
|
||||
// numeric string y; treating either as [y, category] would fabricate
|
||||
// points the chart never plotted.
|
||||
expect(
|
||||
canSonifyChart(
|
||||
series([
|
||||
{
|
||||
type: "line",
|
||||
data: [
|
||||
[1, "-"],
|
||||
[2, "-"],
|
||||
],
|
||||
},
|
||||
])
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
canSonifyChart(
|
||||
series([
|
||||
{
|
||||
type: "line",
|
||||
data: [
|
||||
[1, "8"],
|
||||
[2, "12"],
|
||||
],
|
||||
},
|
||||
])
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reads a series value-first only when every item has that shape", () => {
|
||||
// A lone [number, string] among ordinary pairs is an unreadable value, not
|
||||
// a transposed one.
|
||||
expect(
|
||||
canSonifyChart(
|
||||
series([
|
||||
{
|
||||
type: "bar",
|
||||
data: [
|
||||
[12.5, "sensor.a"],
|
||||
["-", "sensor.b"],
|
||||
],
|
||||
},
|
||||
])
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a chart left with a single readable point", () => {
|
||||
// The device pie's slices are all unreadable, leaving only its one-number
|
||||
// total series — nothing the arrow keys could move between.
|
||||
// One device slice alone offers nothing the arrow keys could move between.
|
||||
expect(
|
||||
canSonifyChart(
|
||||
series([
|
||||
{ type: "pie", data: [{ value: [12.5, "sensor.a"] }] },
|
||||
{ type: "pie", data: [24.5] },
|
||||
])
|
||||
series([{ type: "pie", data: [{ value: [12.5, "sensor.a"] }] }])
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -190,3 +264,163 @@ describe("canSonifyChart", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sonifyChart", () => {
|
||||
const mockedConnect = vi.mocked(connect);
|
||||
|
||||
const fakeChart = (option: Record<string, unknown>) =>
|
||||
({
|
||||
getOption: () => option,
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
}) as unknown as EChartsType;
|
||||
|
||||
const sonify = (
|
||||
option: Record<string, unknown>,
|
||||
formatLabel?: (label: string) => string | undefined
|
||||
) =>
|
||||
sonifyChart(fakeChart(option), {
|
||||
cc: document.createElement("div"),
|
||||
localize: ((key: string) => key) as LocalizeFunc,
|
||||
locale: { language: "en" } as FrontendLocaleData,
|
||||
config: {} as HomeAssistant["config"],
|
||||
formatLabel,
|
||||
onError: () => undefined,
|
||||
});
|
||||
|
||||
const connectedAxes = () => mockedConnect.mock.lastCall![1]!.axes!;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedConnect.mockClear();
|
||||
});
|
||||
|
||||
it("swaps the axis label sources on horizontal charts", async () => {
|
||||
const sonification = await sonify({
|
||||
xAxis: [{ type: "value", name: "kWh" }],
|
||||
yAxis: [{ type: "category", name: "Device", data: ["a", "b"] }],
|
||||
series: [
|
||||
{
|
||||
type: "bar",
|
||||
data: [
|
||||
[12.5, "a"],
|
||||
[7.25, "b"],
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(sonification).not.toBeNull();
|
||||
// The announced x walks the categories of the y axis, and the announced y
|
||||
// is the value from the x axis.
|
||||
expect(connectedAxes()).toMatchObject({
|
||||
x: { label: "Device" },
|
||||
y: { label: "kWh" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the axis label sources on vertical charts", async () => {
|
||||
await sonify({
|
||||
xAxis: [{ type: "category", name: "Month", data: ["Jan", "Feb"] }],
|
||||
yAxis: [{ type: "value", name: "kWh" }],
|
||||
series: [{ type: "bar", data: [5, 9] }],
|
||||
});
|
||||
|
||||
expect(connectedAxes()).toMatchObject({
|
||||
x: { label: "Month" },
|
||||
y: { label: "kWh" },
|
||||
});
|
||||
// Without a formatter the extension's own labels stay untouched.
|
||||
expect(connectedAxes().x.valueLabels).toBeUndefined();
|
||||
});
|
||||
|
||||
it("announces category keys through the label formatter", async () => {
|
||||
await sonify(
|
||||
{
|
||||
xAxis: [{ type: "value", name: "kWh" }],
|
||||
yAxis: [{ type: "category", data: ["sensor.a", "sensor.b"] }],
|
||||
series: [
|
||||
{
|
||||
type: "bar",
|
||||
data: [
|
||||
{ name: "sensor.a", value: [12.5, "sensor.a"] },
|
||||
{ name: "sensor.b", value: [7.25, "sensor.b"] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
(label) => (label === "sensor.a" ? "Dishwasher" : "Oven")
|
||||
);
|
||||
|
||||
expect(connectedAxes().x.valueLabels).toEqual(["Dishwasher", "Oven"]);
|
||||
});
|
||||
|
||||
it("formats pie slice names and keeps the ones the formatter declines", async () => {
|
||||
await sonify(
|
||||
{
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
data: [
|
||||
{ name: "sensor.a", value: [12.5, "sensor.a"] },
|
||||
{ name: "Untracked consumption", value: [7.25, "untracked"] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
(label) => (label === "sensor.a" ? "Dishwasher" : undefined)
|
||||
);
|
||||
|
||||
expect(connectedAxes().x.valueLabels).toEqual([
|
||||
"Dishwasher",
|
||||
"Untracked consumption",
|
||||
]);
|
||||
});
|
||||
|
||||
it("labels pie slices even when the options carry hidden empty axes", async () => {
|
||||
// ha-chart-base's merged options include default axes even for pies, so
|
||||
// an empty category axis must not block the item-name labels.
|
||||
await sonify(
|
||||
{
|
||||
xAxis: [{ type: "category", show: false, data: [] }],
|
||||
yAxis: [{ type: "value", show: false }],
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
data: [
|
||||
{ name: "sensor.a", value: [12.5, "sensor.a"] },
|
||||
{ name: "sensor.b", value: [7.25, "sensor.b"] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
(label) => (label === "sensor.a" ? "Dishwasher" : "Oven")
|
||||
);
|
||||
|
||||
expect(connectedAxes().x.valueLabels).toEqual(["Dishwasher", "Oven"]);
|
||||
});
|
||||
|
||||
it("never overrides labels on time axis charts", async () => {
|
||||
await sonify(
|
||||
{
|
||||
xAxis: [{ type: "time" }],
|
||||
yAxis: [{ type: "value", name: "°C" }],
|
||||
series: [
|
||||
{
|
||||
type: "line",
|
||||
data: [
|
||||
[1700000000000, 21.5],
|
||||
[1700003600000, 22.1],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
() => "wrong"
|
||||
);
|
||||
|
||||
expect(connectedAxes()).toMatchObject({
|
||||
x: { label: "ui.components.history_charts.time" },
|
||||
y: { label: "°C" },
|
||||
});
|
||||
expect(connectedAxes().x.valueLabels).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import {
|
||||
entityTypeFilterFunc,
|
||||
entityTypesNeedStates,
|
||||
parseEntityType,
|
||||
usedEntityTypes,
|
||||
} from "../../../src/data/entity/entity_type";
|
||||
|
||||
const state = (entityId: string, deviceClass?: string): HassEntity =>
|
||||
({
|
||||
entity_id: entityId,
|
||||
attributes: deviceClass ? { device_class: deviceClass } : {},
|
||||
}) as unknown as HassEntity;
|
||||
|
||||
const makeStates = (...entities: HassEntity[]): HomeAssistant["states"] =>
|
||||
Object.fromEntries(entities.map((entity) => [entity.entity_id, entity]));
|
||||
|
||||
describe("parseEntityType", () => {
|
||||
it("reads a domain and a device class", () => {
|
||||
expect(parseEntityType("sensor")).toEqual({ domain: "sensor" });
|
||||
expect(parseEntityType("sensor/power")).toEqual({
|
||||
domain: "sensor",
|
||||
deviceClass: "power",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("entityTypesNeedStates", () => {
|
||||
it("only needs the states for a device class", () => {
|
||||
expect(entityTypesNeedStates(["light", "cover"])).toBe(false);
|
||||
expect(entityTypesNeedStates(["light", "sensor/power"])).toBe(true);
|
||||
expect(entityTypesNeedStates(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usedEntityTypes", () => {
|
||||
it("splits a domain by device class, none included", () => {
|
||||
const types = usedEntityTypes(
|
||||
makeStates(
|
||||
state("binary_sensor.front_door", "door"),
|
||||
state("binary_sensor.hall_motion", "motion"),
|
||||
state("binary_sensor.unknown")
|
||||
)
|
||||
);
|
||||
|
||||
expect(types.get("binary_sensor")?.sort()).toEqual([
|
||||
"door",
|
||||
"motion",
|
||||
"none",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a single-bucket domain whole", () => {
|
||||
const types = usedEntityTypes(
|
||||
makeStates(
|
||||
state("light.kitchen"),
|
||||
state("cover.garage", "garage"),
|
||||
state("cover.gate", "garage")
|
||||
)
|
||||
);
|
||||
|
||||
expect(types.get("light")).toEqual([]);
|
||||
expect(types.get("cover")).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores a device class on a domain that has none", () => {
|
||||
const types = usedEntityTypes(makeStates(state("light.kitchen", "bogus")));
|
||||
|
||||
expect(types.get("light")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("entityTypeFilterFunc", () => {
|
||||
const states = makeStates(
|
||||
state("binary_sensor.front_door", "door"),
|
||||
state("binary_sensor.hall_motion", "motion"),
|
||||
state("binary_sensor.unknown"),
|
||||
state("light.kitchen")
|
||||
);
|
||||
|
||||
it("matches a whole domain", () => {
|
||||
const matches = entityTypeFilterFunc(["binary_sensor"], states);
|
||||
|
||||
expect(matches("binary_sensor.front_door")).toBe(true);
|
||||
expect(matches("binary_sensor.unknown")).toBe(true);
|
||||
expect(matches("light.kitchen")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches a device class", () => {
|
||||
const matches = entityTypeFilterFunc(["binary_sensor/door"], states);
|
||||
|
||||
expect(matches("binary_sensor.front_door")).toBe(true);
|
||||
expect(matches("binary_sensor.hall_motion")).toBe(false);
|
||||
expect(matches("binary_sensor.unknown")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches the entities that carry no device class", () => {
|
||||
const matches = entityTypeFilterFunc(["binary_sensor/none"], states);
|
||||
|
||||
expect(matches("binary_sensor.unknown")).toBe(true);
|
||||
expect(matches("binary_sensor.front_door")).toBe(false);
|
||||
});
|
||||
|
||||
it("unions the selection", () => {
|
||||
const matches = entityTypeFilterFunc(
|
||||
["light", "binary_sensor/door"],
|
||||
states
|
||||
);
|
||||
|
||||
expect(matches("light.kitchen")).toBe(true);
|
||||
expect(matches("binary_sensor.front_door")).toBe(true);
|
||||
expect(matches("binary_sensor.hall_motion")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps an entity without a state only for its domain", () => {
|
||||
const matches = entityTypeFilterFunc(["binary_sensor"], states);
|
||||
const narrowed = entityTypeFilterFunc(["binary_sensor/none"], states);
|
||||
|
||||
expect(matches("binary_sensor.disabled")).toBe(true);
|
||||
expect(narrowed("binary_sensor.disabled")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ConfigEntry } from "../../src/data/config_entries";
|
||||
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
|
||||
import type { ESPHomeDeviceCapabilities } from "../../src/data/esphome";
|
||||
import {
|
||||
countRemainingESPHomeCapabilities,
|
||||
deriveESPHomeSetupStatus,
|
||||
deviceHasMediaPlayerEntity,
|
||||
getESPHomeSetupCapabilityIds,
|
||||
hasESPHomeSetupCapabilities,
|
||||
hasStartedNonBluetoothESPHomeSetup,
|
||||
hasZWaveJSEntryForDevice,
|
||||
isESPHomeSetupDeferred,
|
||||
withDeferredESPHomeDevice,
|
||||
} from "../../src/data/esphome_setup";
|
||||
|
||||
const capabilities = (
|
||||
overrides: Partial<ESPHomeDeviceCapabilities> = {}
|
||||
): ESPHomeDeviceCapabilities => ({
|
||||
available: true,
|
||||
bluetooth_proxy: { supported: false },
|
||||
zwave_proxy: {
|
||||
supported: false,
|
||||
home_id: 0,
|
||||
},
|
||||
serial_proxies: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const deriveOptions = (
|
||||
overrides: Partial<{
|
||||
mediaPlayerSupported: boolean;
|
||||
musicAssistantLoaded: boolean;
|
||||
zwaveJsEntryExists: boolean;
|
||||
}> = {}
|
||||
) => ({
|
||||
mediaPlayerSupported: false,
|
||||
musicAssistantLoaded: false,
|
||||
zwaveJsEntryExists: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const entry = (overrides: Partial<ConfigEntry>): ConfigEntry =>
|
||||
({
|
||||
entry_id: "entry",
|
||||
domain: "esphome",
|
||||
title: "Device",
|
||||
source: "user",
|
||||
state: "loaded",
|
||||
supports_options: true,
|
||||
supports_remove_device: false,
|
||||
supports_unload: true,
|
||||
supports_reconfigure: false,
|
||||
supported_subentry_types: {},
|
||||
num_subentries: 0,
|
||||
pref_disable_new_entities: false,
|
||||
pref_disable_polling: false,
|
||||
disabled_by: null,
|
||||
reason: null,
|
||||
error_reason_translation_key: null,
|
||||
error_reason_translation_placeholders: null,
|
||||
...overrides,
|
||||
}) as ConfigEntry;
|
||||
|
||||
const device = (
|
||||
id: string,
|
||||
overrides: Partial<DeviceRegistryEntry> = {}
|
||||
): DeviceRegistryEntry =>
|
||||
({
|
||||
id,
|
||||
config_entries: [],
|
||||
via_device_id: null,
|
||||
...overrides,
|
||||
}) as DeviceRegistryEntry;
|
||||
|
||||
describe("hasESPHomeSetupCapabilities", () => {
|
||||
it("is false when capabilities are missing", () => {
|
||||
expect(hasESPHomeSetupCapabilities(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when advertised even if the device is unavailable", () => {
|
||||
expect(
|
||||
hasESPHomeSetupCapabilities(
|
||||
capabilities({
|
||||
available: false,
|
||||
bluetooth_proxy: { supported: true },
|
||||
}),
|
||||
{ mediaPlayerSupported: true }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a device with no advertised capabilities", () => {
|
||||
expect(hasESPHomeSetupCapabilities(capabilities())).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when any capability is supported", () => {
|
||||
expect(
|
||||
hasESPHomeSetupCapabilities(
|
||||
capabilities({
|
||||
bluetooth_proxy: { supported: true },
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasESPHomeSetupCapabilities(capabilities(), {
|
||||
mediaPlayerSupported: true,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasESPHomeSetupCapabilities(
|
||||
capabilities({ zwave_proxy: { supported: true, home_id: 0 } })
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasESPHomeSetupCapabilities(
|
||||
capabilities({
|
||||
serial_proxies: [
|
||||
{
|
||||
name: "UART",
|
||||
port_type: "TTL",
|
||||
url: "esphome-hass://proxy/uart",
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deviceHasMediaPlayerEntity", () => {
|
||||
it("is true when the device has a media_player entity", () => {
|
||||
expect(
|
||||
deviceHasMediaPlayerEntity("dev-1", [
|
||||
{ entity_id: "sensor.proxy_rssi", device_id: "dev-1" },
|
||||
{ entity_id: "media_player.proxy", device_id: "dev-1" },
|
||||
])
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores media players on other devices and non-media entities", () => {
|
||||
expect(
|
||||
deviceHasMediaPlayerEntity("dev-1", [
|
||||
{ entity_id: "media_player.other", device_id: "dev-2" },
|
||||
{ entity_id: "switch.proxy", device_id: "dev-1" },
|
||||
])
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveESPHomeSetupStatus", () => {
|
||||
it("omits rows that are not supported", () => {
|
||||
expect(deriveESPHomeSetupStatus(capabilities(), deriveOptions())).toEqual(
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it("marks Bluetooth completed when the proxy is compiled in", () => {
|
||||
const status = deriveESPHomeSetupStatus(
|
||||
capabilities({
|
||||
bluetooth_proxy: { supported: true },
|
||||
}),
|
||||
deriveOptions()
|
||||
);
|
||||
|
||||
expect(status.bluetooth).toBe("completed");
|
||||
expect(status.audio).toBeUndefined();
|
||||
expect(status.connectivity).toBeUndefined();
|
||||
expect(status.serial).toBeUndefined();
|
||||
});
|
||||
|
||||
it("marks audio from media_player entities, not capabilities", () => {
|
||||
const caps = capabilities();
|
||||
|
||||
expect(
|
||||
deriveESPHomeSetupStatus(
|
||||
caps,
|
||||
deriveOptions({ mediaPlayerSupported: true })
|
||||
).audio
|
||||
).toBe("active");
|
||||
expect(
|
||||
deriveESPHomeSetupStatus(
|
||||
caps,
|
||||
deriveOptions({
|
||||
mediaPlayerSupported: true,
|
||||
musicAssistantLoaded: true,
|
||||
})
|
||||
).audio
|
||||
).toBe("completed");
|
||||
expect(
|
||||
deriveESPHomeSetupStatus(caps, deriveOptions()).audio
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("derives connectivity from home_id and a zwave_js entry", () => {
|
||||
const supported = capabilities({
|
||||
zwave_proxy: { supported: true, home_id: 0 },
|
||||
});
|
||||
|
||||
expect(
|
||||
deriveESPHomeSetupStatus(supported, deriveOptions()).connectivity
|
||||
).toBe("not-started");
|
||||
|
||||
expect(
|
||||
deriveESPHomeSetupStatus(
|
||||
capabilities({
|
||||
zwave_proxy: { supported: true, home_id: 123456 },
|
||||
}),
|
||||
deriveOptions()
|
||||
).connectivity
|
||||
).toBe("detected");
|
||||
|
||||
expect(
|
||||
deriveESPHomeSetupStatus(
|
||||
capabilities({
|
||||
zwave_proxy: { supported: true, home_id: 123456 },
|
||||
}),
|
||||
deriveOptions({ zwaveJsEntryExists: true })
|
||||
).connectivity
|
||||
).toBe("completed");
|
||||
});
|
||||
|
||||
it("keeps serial at not-started when ports are advertised", () => {
|
||||
expect(
|
||||
deriveESPHomeSetupStatus(
|
||||
capabilities({
|
||||
serial_proxies: [
|
||||
{
|
||||
name: "RS232",
|
||||
port_type: "RS232",
|
||||
url: "esphome-hass://proxy/rs232",
|
||||
},
|
||||
],
|
||||
}),
|
||||
deriveOptions()
|
||||
).serial
|
||||
).toBe("not-started");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remaining capabilities and continue-setup", () => {
|
||||
it("counts incomplete rows and ignores Bluetooth", () => {
|
||||
const status = deriveESPHomeSetupStatus(
|
||||
capabilities({
|
||||
bluetooth_proxy: { supported: true },
|
||||
zwave_proxy: { supported: true, home_id: 0 },
|
||||
serial_proxies: [
|
||||
{ name: "UART", port_type: "TTL", url: "esphome-hass://proxy/uart" },
|
||||
],
|
||||
}),
|
||||
deriveOptions({ mediaPlayerSupported: true })
|
||||
);
|
||||
|
||||
expect(getESPHomeSetupCapabilityIds(status)).toEqual([
|
||||
"bluetooth",
|
||||
"audio",
|
||||
"connectivity",
|
||||
"serial",
|
||||
]);
|
||||
expect(getESPHomeSetupCapabilityIds(status).length).toBe(4);
|
||||
expect(countRemainingESPHomeCapabilities(status)).toBe(3);
|
||||
expect(hasStartedNonBluetoothESPHomeSetup(status)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not count Bluetooth when the proxy is unsupported", () => {
|
||||
const status = deriveESPHomeSetupStatus(
|
||||
capabilities({
|
||||
zwave_proxy: { supported: true, home_id: 0 },
|
||||
serial_proxies: [
|
||||
{ name: "UART", port_type: "TTL", url: "esphome-hass://proxy/uart" },
|
||||
],
|
||||
}),
|
||||
deriveOptions({ mediaPlayerSupported: true })
|
||||
);
|
||||
|
||||
expect(getESPHomeSetupCapabilityIds(status)).toEqual([
|
||||
"audio",
|
||||
"connectivity",
|
||||
"serial",
|
||||
]);
|
||||
expect(getESPHomeSetupCapabilityIds(status).length).toBe(3);
|
||||
});
|
||||
|
||||
it("does not treat Bluetooth-only setup as started", () => {
|
||||
const status = deriveESPHomeSetupStatus(
|
||||
capabilities({
|
||||
bluetooth_proxy: { supported: true },
|
||||
}),
|
||||
deriveOptions()
|
||||
);
|
||||
|
||||
expect(countRemainingESPHomeCapabilities(status)).toBe(0);
|
||||
expect(hasStartedNonBluetoothESPHomeSetup(status)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports everything set up when remaining is zero", () => {
|
||||
const status = deriveESPHomeSetupStatus(
|
||||
capabilities({
|
||||
bluetooth_proxy: { supported: true },
|
||||
}),
|
||||
deriveOptions({
|
||||
mediaPlayerSupported: true,
|
||||
musicAssistantLoaded: true,
|
||||
})
|
||||
);
|
||||
|
||||
expect(countRemainingESPHomeCapabilities(status)).toBe(0);
|
||||
expect(hasStartedNonBluetoothESPHomeSetup(status)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasZWaveJSEntryForDevice", () => {
|
||||
it("detects a zwave_js entry on the ESPHome device itself", () => {
|
||||
const devices = {
|
||||
esphome: device("esphome", {
|
||||
config_entries: ["esphome-entry", "zwave-entry"],
|
||||
}),
|
||||
};
|
||||
const entries = [
|
||||
entry({ entry_id: "esphome-entry", domain: "esphome" }),
|
||||
entry({ entry_id: "zwave-entry", domain: "zwave_js" }),
|
||||
];
|
||||
|
||||
expect(hasZWaveJSEntryForDevice("esphome", devices, entries)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects a child device via this ESPHome device", () => {
|
||||
const devices = {
|
||||
esphome: device("esphome", { config_entries: ["esphome-entry"] }),
|
||||
controller: device("controller", {
|
||||
config_entries: ["zwave-entry"],
|
||||
via_device_id: "esphome",
|
||||
}),
|
||||
};
|
||||
const entries = [
|
||||
entry({ entry_id: "esphome-entry", domain: "esphome" }),
|
||||
entry({ entry_id: "zwave-entry", domain: "zwave_js" }),
|
||||
];
|
||||
|
||||
expect(hasZWaveJSEntryForDevice("esphome", devices, entries)).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores disabled zwave_js entries and unrelated devices", () => {
|
||||
const devices = {
|
||||
esphome: device("esphome", { config_entries: ["esphome-entry"] }),
|
||||
other: device("other", {
|
||||
config_entries: ["zwave-disabled"],
|
||||
via_device_id: "someone-else",
|
||||
}),
|
||||
};
|
||||
const entries = [
|
||||
entry({ entry_id: "esphome-entry", domain: "esphome" }),
|
||||
entry({
|
||||
entry_id: "zwave-disabled",
|
||||
domain: "zwave_js",
|
||||
disabled_by: "user",
|
||||
}),
|
||||
];
|
||||
|
||||
expect(hasZWaveJSEntryForDevice("esphome", devices, entries)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Later persistence helpers", () => {
|
||||
it("tracks deferred device ids per user data", () => {
|
||||
expect(isESPHomeSetupDeferred(undefined, "dev-1")).toBe(false);
|
||||
expect(isESPHomeSetupDeferred({ setupDeferred: ["dev-1"] }, "dev-1")).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
const next = withDeferredESPHomeDevice(
|
||||
{ setupDeferred: ["dev-1"] },
|
||||
"dev-2"
|
||||
);
|
||||
expect(next.setupDeferred).toEqual(["dev-1", "dev-2"]);
|
||||
expect(withDeferredESPHomeDevice(next, "dev-1").setupDeferred).toEqual([
|
||||
"dev-1",
|
||||
"dev-2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -27,11 +27,37 @@ vi.mock("../../../src/components/ha-dialog-footer", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
const mockForm = vi.hoisted(() => ({
|
||||
delayedTag: undefined as string | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/components/ha-form/ha-form", () => {
|
||||
if (!customElements.get("ha-selector")) {
|
||||
customElements.define("ha-selector", class extends HTMLElement {});
|
||||
}
|
||||
customElements.define(
|
||||
"ha-form",
|
||||
class extends HTMLElement {
|
||||
public reportValidity = vi.fn(() => true);
|
||||
|
||||
public connectedCallback(): void {
|
||||
if (this.shadowRoot) {
|
||||
return;
|
||||
}
|
||||
if (mockForm.delayedTag) {
|
||||
const selector = document.createElement("ha-selector");
|
||||
selector
|
||||
.attachShadow({ mode: "open" })
|
||||
.append(document.createElement(mockForm.delayedTag));
|
||||
this.attachShadow({ mode: "open" }).append(selector);
|
||||
return;
|
||||
}
|
||||
const selector = document.createElement("div");
|
||||
selector
|
||||
.attachShadow({ mode: "open" })
|
||||
.append(document.createElement("input"));
|
||||
this.attachShadow({ mode: "open" }).append(selector);
|
||||
}
|
||||
}
|
||||
);
|
||||
return {};
|
||||
@@ -43,6 +69,31 @@ const getInternals = (dialog: DialogForm) =>
|
||||
const getForms = (dialog: DialogForm): HTMLElement[] =>
|
||||
Array.from(dialog.shadowRoot!.querySelectorAll("ha-form"));
|
||||
|
||||
const formControl = (form: HTMLElement): HTMLElement | null => {
|
||||
const visit = (node: ParentNode): HTMLElement | null => {
|
||||
if (node instanceof Element && node.shadowRoot) {
|
||||
const inShadow = visit(node.shadowRoot);
|
||||
if (inShadow) {
|
||||
return inShadow;
|
||||
}
|
||||
}
|
||||
for (const child of node.children) {
|
||||
if (
|
||||
child instanceof HTMLElement &&
|
||||
child.matches("input, textarea, button")
|
||||
) {
|
||||
return child;
|
||||
}
|
||||
const found = visit(child);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return visit(form);
|
||||
};
|
||||
|
||||
const outerParams = (data: FormDialogData = {}): FormDialogParams => ({
|
||||
title: "Outer",
|
||||
schema: [{ name: "value", selector: { text: {} } }],
|
||||
@@ -96,6 +147,7 @@ const cancel = (dialog: DialogForm) =>
|
||||
(getInternals(dialog)["_cancel"] as () => void)();
|
||||
|
||||
afterEach(() => {
|
||||
mockForm.delayedTag = undefined;
|
||||
document.body.replaceChildren();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -130,6 +182,23 @@ describe("dialog-form mounted nested forms", () => {
|
||||
expect(getForms(dialog)[0].hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("moves focus to the first nested form control when a level is pushed", async () => {
|
||||
const dialog = await openDialog();
|
||||
const parent = getForms(dialog)[0];
|
||||
const opener = document.createElement("button");
|
||||
parent.append(opener);
|
||||
opener.focus();
|
||||
|
||||
expect(deepActiveElement()).toBe(opener);
|
||||
|
||||
await showNestedDialog(dialog, parent, nestedParams());
|
||||
|
||||
await vi.waitUntil(
|
||||
() => deepActiveElement() === formControl(getForms(dialog)[1])
|
||||
);
|
||||
expect(getForms(dialog)[0].hidden).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["submit", "cancel"] as const)(
|
||||
"restores focus to the opener after nested %s",
|
||||
async (action) => {
|
||||
@@ -140,6 +209,9 @@ describe("dialog-form mounted nested forms", () => {
|
||||
opener.focus();
|
||||
|
||||
await showNestedDialog(dialog, parent, nestedParams());
|
||||
await vi.waitUntil(
|
||||
() => deepActiveElement() === formControl(getForms(dialog)[1])
|
||||
);
|
||||
|
||||
const child = getForms(dialog)[1];
|
||||
const childFocusTarget = document.createElement("button");
|
||||
@@ -158,6 +230,72 @@ describe("dialog-form mounted nested forms", () => {
|
||||
}
|
||||
);
|
||||
|
||||
it("focuses the first parent form control when the opener is gone after nested submit", async () => {
|
||||
const dialog = await openDialog();
|
||||
const parent = getForms(dialog)[0];
|
||||
const opener = document.createElement("button");
|
||||
parent.append(opener);
|
||||
opener.focus();
|
||||
|
||||
await showNestedDialog(dialog, parent, nestedParams());
|
||||
await vi.waitUntil(
|
||||
() => deepActiveElement() === formControl(getForms(dialog)[1])
|
||||
);
|
||||
opener.remove();
|
||||
submit(dialog);
|
||||
|
||||
await vi.waitUntil(
|
||||
() => deepActiveElement() === formControl(getForms(dialog)[0])
|
||||
);
|
||||
});
|
||||
|
||||
it("focuses a nested control after a cold selector chunk upgrades", async () => {
|
||||
const tag = `ha-test-delayed-selector-${crypto.randomUUID()}`;
|
||||
const dialog = await openDialog();
|
||||
const parent = getForms(dialog)[0];
|
||||
const opener = document.createElement("button");
|
||||
parent.append(opener);
|
||||
opener.focus();
|
||||
|
||||
mockForm.delayedTag = tag;
|
||||
await showNestedDialog(dialog, parent, nestedParams());
|
||||
|
||||
customElements.define(
|
||||
tag,
|
||||
class extends HTMLElement {
|
||||
public connectedCallback(): void {
|
||||
if (this.shadowRoot) {
|
||||
return;
|
||||
}
|
||||
this.attachShadow({ mode: "open" }).append(
|
||||
document.createElement("input")
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await vi.waitUntil(
|
||||
() => deepActiveElement() === formControl(getForms(dialog)[1])
|
||||
);
|
||||
});
|
||||
|
||||
it("does not steal focus after a nested level is immediately cancelled", async () => {
|
||||
const dialog = await openDialog();
|
||||
const parent = getForms(dialog)[0];
|
||||
const opener = document.createElement("button");
|
||||
parent.append(opener);
|
||||
opener.focus();
|
||||
|
||||
await showNestedDialog(dialog, parent, nestedParams());
|
||||
cancel(dialog);
|
||||
|
||||
await vi.waitUntil(() => deepActiveElement() === opener);
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 50);
|
||||
});
|
||||
expect(deepActiveElement()).toBe(opener);
|
||||
});
|
||||
|
||||
it("keeps the parent open after a custom object selector nested save", async () => {
|
||||
const dialog = await openDialog();
|
||||
const nested = {
|
||||
|
||||
@@ -4697,22 +4697,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/client@npm:1.6.2":
|
||||
version: 1.6.2
|
||||
resolution: "@rsdoctor/client@npm:1.6.2"
|
||||
checksum: 10/7b57cf3a9c6e3dc1b64b0f07969330579fd0e84963843cb3517b7c1160bc9b2fa08e709eaf835139d8a7e8c32977af8aae050d100df80daed44371ced8272642
|
||||
"@rsdoctor/client@npm:1.6.3":
|
||||
version: 1.6.3
|
||||
resolution: "@rsdoctor/client@npm:1.6.3"
|
||||
checksum: 10/407d2509dc829e8f89aada7690b63c98a1d18ecffd3e23740be5f506a043cbc16d75e1f0a80f1299b60ca07d6151b7fae848568dd53b71e211dcefc163f55dd5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/core@npm:1.6.2":
|
||||
version: 1.6.2
|
||||
resolution: "@rsdoctor/core@npm:1.6.2"
|
||||
"@rsdoctor/core@npm:1.6.3":
|
||||
version: 1.6.3
|
||||
resolution: "@rsdoctor/core@npm:1.6.3"
|
||||
dependencies:
|
||||
"@rsbuild/plugin-check-syntax": "npm:^1.6.1"
|
||||
"@rsdoctor/graph": "npm:1.6.2"
|
||||
"@rsdoctor/sdk": "npm:1.6.2"
|
||||
"@rsdoctor/types": "npm:1.6.2"
|
||||
"@rsdoctor/utils": "npm:1.6.2"
|
||||
"@rsdoctor/graph": "npm:1.6.3"
|
||||
"@rsdoctor/sdk": "npm:1.6.3"
|
||||
"@rsdoctor/types": "npm:1.6.3"
|
||||
"@rsdoctor/utils": "npm:1.6.3"
|
||||
"@rspack/resolver": "npm:^0.2.8"
|
||||
browserslist-load-config: "npm:^1.0.2"
|
||||
es-toolkit: "npm:^1.49.0"
|
||||
@@ -4720,60 +4720,60 @@ __metadata:
|
||||
fs-extra: "npm:^11.1.1"
|
||||
semver: "npm:^7.8.5"
|
||||
source-map: "npm:^0.7.6"
|
||||
checksum: 10/803bb41042b2af07b096f2dde6383d516bb22b62705aeb9693959788e8a63df80e6d25514f1d92639a267e46e73732689abf5ba2c25679fbd80da9dd126f7ed9
|
||||
checksum: 10/89da82b0cf60be5560c5f0b9ac2fd8695ca2d53b0bed698202ef12182b507c8021989e4a118f27a36d6791f1e0998e9d58e44f5f564cfe03b33e4b0288324780
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/graph@npm:1.6.2":
|
||||
version: 1.6.2
|
||||
resolution: "@rsdoctor/graph@npm:1.6.2"
|
||||
"@rsdoctor/graph@npm:1.6.3":
|
||||
version: 1.6.3
|
||||
resolution: "@rsdoctor/graph@npm:1.6.3"
|
||||
dependencies:
|
||||
"@rsdoctor/types": "npm:1.6.2"
|
||||
"@rsdoctor/utils": "npm:1.6.2"
|
||||
"@rsdoctor/types": "npm:1.6.3"
|
||||
"@rsdoctor/utils": "npm:1.6.3"
|
||||
es-toolkit: "npm:^1.49.0"
|
||||
path-browserify: "npm:1.0.1"
|
||||
source-map: "npm:^0.7.6"
|
||||
checksum: 10/f28aaedc529a6cff782f1813faa6d19d10f76ab8f529bbe2899385b5659d247f0543149e2fcd700264a00e9568605a85fde04cbf634e821f24e9bf239eb1b009
|
||||
checksum: 10/b91aed0faed7771d74cd27015ee0e3c1bf81b8783d701b867328945f1533d90ac4590d0aa5da06b56126fa7caaaa0ea0dbbf66dcec2d64a1da98b436dab3f0dd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/rspack-plugin@npm:1.6.2":
|
||||
version: 1.6.2
|
||||
resolution: "@rsdoctor/rspack-plugin@npm:1.6.2"
|
||||
"@rsdoctor/rspack-plugin@npm:1.6.3":
|
||||
version: 1.6.3
|
||||
resolution: "@rsdoctor/rspack-plugin@npm:1.6.3"
|
||||
dependencies:
|
||||
"@rsdoctor/core": "npm:1.6.2"
|
||||
"@rsdoctor/graph": "npm:1.6.2"
|
||||
"@rsdoctor/sdk": "npm:1.6.2"
|
||||
"@rsdoctor/types": "npm:1.6.2"
|
||||
"@rsdoctor/utils": "npm:1.6.2"
|
||||
"@rsdoctor/core": "npm:1.6.3"
|
||||
"@rsdoctor/graph": "npm:1.6.3"
|
||||
"@rsdoctor/sdk": "npm:1.6.3"
|
||||
"@rsdoctor/types": "npm:1.6.3"
|
||||
"@rsdoctor/utils": "npm:1.6.3"
|
||||
peerDependencies:
|
||||
"@rspack/core": "*"
|
||||
peerDependenciesMeta:
|
||||
"@rspack/core":
|
||||
optional: true
|
||||
checksum: 10/dec9f0424d14ec7ed03cd6cbc5e0b11cd74c29be12c3f79466bf8da0f317a4bdba858a58daf64e4a7c16d20a1d58433b1d2c038b62ff771c1d045bca3d71c3ad
|
||||
checksum: 10/3b1cc8611ba2e2047a1ac8143aa8e5749e6a19fdb021dfa33ea06ad9a7ac78cb4a025baacce3a6d659107ee612d34c7bbabfeba15784003e0efa376f4dc00071
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/sdk@npm:1.6.2":
|
||||
version: 1.6.2
|
||||
resolution: "@rsdoctor/sdk@npm:1.6.2"
|
||||
"@rsdoctor/sdk@npm:1.6.3":
|
||||
version: 1.6.3
|
||||
resolution: "@rsdoctor/sdk@npm:1.6.3"
|
||||
dependencies:
|
||||
"@rsdoctor/client": "npm:1.6.2"
|
||||
"@rsdoctor/graph": "npm:1.6.2"
|
||||
"@rsdoctor/types": "npm:1.6.2"
|
||||
"@rsdoctor/utils": "npm:1.6.2"
|
||||
"@rsdoctor/client": "npm:1.6.3"
|
||||
"@rsdoctor/graph": "npm:1.6.3"
|
||||
"@rsdoctor/types": "npm:1.6.3"
|
||||
"@rsdoctor/utils": "npm:1.6.3"
|
||||
launch-editor: "npm:^2.13.2"
|
||||
safer-buffer: "npm:2.1.2"
|
||||
socket.io: "npm:4.8.1"
|
||||
tapable: "npm:2.3.3"
|
||||
checksum: 10/89d5850145b490bbf3f43486176ebdd1f9eb67cdbe96a07549bb0b400ab76d85f3b6d5667be7cf8304434281f0db74fbaaab3360228eb1650f0a8ce640101208
|
||||
checksum: 10/9363930b637869262c93913a0bc90e76c7662cebf869778300b6184e82fe41ca3af8b065c774327557b22f7e24a3ed517ff1db437f161297c11d03894e25c930
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/types@npm:1.6.2":
|
||||
version: 1.6.2
|
||||
resolution: "@rsdoctor/types@npm:1.6.2"
|
||||
"@rsdoctor/types@npm:1.6.3":
|
||||
version: 1.6.3
|
||||
resolution: "@rsdoctor/types@npm:1.6.3"
|
||||
dependencies:
|
||||
"@types/connect": "npm:3.4.38"
|
||||
"@types/estree": "npm:1.0.5"
|
||||
@@ -4787,16 +4787,16 @@ __metadata:
|
||||
optional: true
|
||||
webpack:
|
||||
optional: true
|
||||
checksum: 10/39328739b31f7df46b663a9f6b5ac8c1e290c6b7ce0f68d9b45a423b89a1a296d32fbee0c26f555fc40d60b356a3605909f7bbe19c77d9c6bca3603d6928aef2
|
||||
checksum: 10/b3ee6089b671ac0f2dc656f06a03ef475e982f44b34faa664bd7f9121a6ec7609d35cd814f093b7bf3326c8fce64998542fca0645fc4c0ad7e3c136c23e90a0d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/utils@npm:1.6.2":
|
||||
version: 1.6.2
|
||||
resolution: "@rsdoctor/utils@npm:1.6.2"
|
||||
"@rsdoctor/utils@npm:1.6.3":
|
||||
version: 1.6.3
|
||||
resolution: "@rsdoctor/utils@npm:1.6.3"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:7.26.2"
|
||||
"@rsdoctor/types": "npm:1.6.2"
|
||||
"@rsdoctor/types": "npm:1.6.3"
|
||||
"@types/estree": "npm:1.0.5"
|
||||
acorn: "npm:^8.10.0"
|
||||
acorn-import-attributes: "npm:^1.9.5"
|
||||
@@ -4810,7 +4810,7 @@ __metadata:
|
||||
picocolors: "npm:^1.1.1"
|
||||
rslog: "npm:^2.1.2"
|
||||
strip-ansi: "npm:^7.2.0"
|
||||
checksum: 10/fb6a147e452756018e93789e07dbe015354fc2690699755ef411748a393c7db86fdff2614cdf9ce07822434abcac3eef362aea8c63cbca9fe3f96c958fde56dc
|
||||
checksum: 10/06b18159d64809e9c764371a00e284d344c2912b17e6eebe71bd04fe6c6e5bc05bd4bd7dd5860351f3c9c434b96ff1acbaa7d0be02f0b44f6fed42ddb18748ae
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -4996,9 +4996,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/dev-server@npm:2.2.0":
|
||||
version: 2.2.0
|
||||
resolution: "@rspack/dev-server@npm:2.2.0"
|
||||
"@rspack/dev-server@npm:2.2.1":
|
||||
version: 2.2.1
|
||||
resolution: "@rspack/dev-server@npm:2.2.1"
|
||||
dependencies:
|
||||
"@rspack/dev-middleware": "npm:^2.0.3"
|
||||
peerDependencies:
|
||||
@@ -5007,7 +5007,7 @@ __metadata:
|
||||
peerDependenciesMeta:
|
||||
selfsigned:
|
||||
optional: true
|
||||
checksum: 10/edfc57223fad64c43c96be95b0b6650194ac2e02d929bef649d524faa9c618233afe8f970ac2e21c77e1b92f6c65dac20f06817e73a9bd661f43c73057ae6b6b
|
||||
checksum: 10/4ae7a1a5e3ce1dc03c3fa43832fb01f9153a0674474445051917de6d3bf874a10377b4eb95ec9df33ad817a92110c2fa5eeb791d75bcf8130c1d57309f2a29db
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -5735,10 +5735,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/luxon@npm:3.7.4":
|
||||
version: 3.7.4
|
||||
resolution: "@types/luxon@npm:3.7.4"
|
||||
checksum: 10/f062cbf6cfac319b88abcedd253308c6dc98e0b76e1ab991fb9fb1573c602097f2d54a95ea874d3678f3963a73478bcce260c621deb6c779c83ff891611e46d7
|
||||
"@types/luxon@npm:3.7.5":
|
||||
version: 3.7.5
|
||||
resolution: "@types/luxon@npm:3.7.5"
|
||||
checksum: 10/656e6e5c057f5e9769d2f2c0d84795de3d68d461557dca432dbdf6b98d1e57b15f273d99781c1e8d786f1fd15dcc29eeb48496685aa77fc3f14e186b6230ab0e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8970,9 +8970,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eslint@npm:10.8.1":
|
||||
version: 10.8.1
|
||||
resolution: "eslint@npm:10.8.1"
|
||||
"eslint@npm:10.9.0":
|
||||
version: 10.9.0
|
||||
resolution: "eslint@npm:10.9.0"
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils": "npm:^4.8.0"
|
||||
"@eslint-community/regexpp": "npm:^4.12.2"
|
||||
@@ -9011,7 +9011,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
eslint: bin/eslint.js
|
||||
checksum: 10/7174710e740f95bfa97d7501419d717c03cf7760c3eea4a91cd10fb804538bbd8cdc0849b26afa789a56a89ee838b727e4b9329e347376f96b8e524727b90ba3
|
||||
checksum: 10/1cd63bbf4f2c003ed759ab6ae9e2030be0abafdcd7b9ffb5be9e9c3aa3ea6142e6af0a337aac21044957750807fde06c9d052e2c9734cca6792dc2d095f9c180
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -10024,9 +10024,9 @@ __metadata:
|
||||
"@octokit/rest": "npm:22.0.1"
|
||||
"@playwright/test": "npm:1.62.1"
|
||||
"@replit/codemirror-indentation-markers": "npm:6.5.3"
|
||||
"@rsdoctor/rspack-plugin": "npm:1.6.2"
|
||||
"@rsdoctor/rspack-plugin": "npm:1.6.3"
|
||||
"@rspack/core": "npm:2.1.10"
|
||||
"@rspack/dev-server": "npm:2.2.0"
|
||||
"@rspack/dev-server": "npm:2.2.1"
|
||||
"@swc/helpers": "npm:0.5.23"
|
||||
"@thomasloven/round-slider": "npm:0.6.0"
|
||||
"@tsparticles/engine": "npm:4.3.2"
|
||||
@@ -10041,7 +10041,7 @@ __metadata:
|
||||
"@types/leaflet-draw": "npm:1.0.13"
|
||||
"@types/leaflet.markercluster": "npm:1.5.6"
|
||||
"@types/lodash.merge": "npm:4.6.9"
|
||||
"@types/luxon": "npm:3.7.4"
|
||||
"@types/luxon": "npm:3.7.5"
|
||||
"@types/qrcode": "npm:1.5.6"
|
||||
"@types/sortablejs": "npm:1.15.9"
|
||||
"@types/tar": "npm:7.0.87"
|
||||
@@ -10070,7 +10070,7 @@ __metadata:
|
||||
echarts: "npm:6.1.0"
|
||||
echarts-extension-chart2music: "npm:0.1.1"
|
||||
element-internals-polyfill: "npm:3.0.2"
|
||||
eslint: "npm:10.8.1"
|
||||
eslint: "npm:10.9.0"
|
||||
eslint-config-prettier: "npm:10.1.8"
|
||||
eslint-import-resolver-webpack: "npm:0.13.11"
|
||||
eslint-plugin-import-x: "npm:4.17.1"
|
||||
|
||||
Reference in New Issue
Block a user