mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-27 09:26:28 +00:00
Compare commits
89
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4205fb799b | ||
|
|
8d3877c388 | ||
|
|
66b9fbd4bf | ||
|
|
a406abfdcd | ||
|
|
fddc6e5506 | ||
|
|
69518334bb | ||
|
|
7a387004d6 | ||
|
|
1e9993785d | ||
|
|
1a3a93e3b9 | ||
|
|
c6dd741bb5 | ||
|
|
24f522b7ae | ||
|
|
0594229a45 | ||
|
|
615fd58324 | ||
|
|
fb35194041 | ||
|
|
d4e3ec858e | ||
|
|
6516b8de68 | ||
|
|
edb2f98d03 | ||
|
|
5ae95a1ea2 | ||
|
|
d0371e506f | ||
|
|
872205c352 | ||
|
|
bf939babfd | ||
|
|
d41b21b9d6 | ||
|
|
7e495f38d2 | ||
|
|
5cff0cb4c9 | ||
|
|
dbe2e2c079 | ||
|
|
e22b770252 | ||
|
|
fd388d99bf | ||
|
|
f9bfc66d5b | ||
|
|
c4ab017aed | ||
|
|
e22ef3e1f3 | ||
|
|
7a3764790b | ||
|
|
c499c2e030 | ||
|
|
13ec52ddbe | ||
|
|
d288cfd30a | ||
|
|
643e8934b4 | ||
|
|
9f3ca39738 | ||
|
|
8cee5155e6 | ||
|
|
15341a0aad | ||
|
|
9f8a2253ac | ||
|
|
97df5a5bf0 | ||
|
|
91c28c2f58 | ||
|
|
1f299d98e0 | ||
|
|
a74926aef6 | ||
|
|
437a4cedf9 | ||
|
|
55913d7505 | ||
|
|
0bb654c70c | ||
|
|
235541748b | ||
|
|
6bd1d698ce | ||
|
|
18862b868f | ||
|
|
662d817a5e | ||
|
|
7412ab5578 | ||
|
|
b2be0bd673 | ||
|
|
a141432546 | ||
|
|
cd50531b67 | ||
|
|
f7438f4d0e | ||
|
|
a7fabb8a8c | ||
|
|
9dab55f39a | ||
|
|
7ac98b7fdd | ||
|
|
5c1c92e8ca | ||
|
|
bf9765d557 | ||
|
|
b013b1f929 | ||
|
|
8026a59e70 | ||
|
|
ffa7cf17a6 | ||
|
|
5a5cec9060 | ||
|
|
bb962ba3d8 | ||
|
|
d4a59ff1c7 | ||
|
|
f496d842a6 | ||
|
|
c14d07c845 | ||
|
|
8a1f2e86bd | ||
|
|
1454f4d081 | ||
|
|
1a47c326b2 | ||
|
|
d8e827e08b | ||
|
|
301c907fa9 | ||
|
|
1ef3bc94d0 | ||
|
|
ba0367be2f | ||
|
|
c186a31056 | ||
|
|
bf8c92c95e | ||
|
|
9743117abf | ||
|
|
5e012973b2 | ||
|
|
ea659f1b33 | ||
|
|
539803cb5b | ||
|
|
944d3332d1 | ||
|
|
4a267d160f | ||
|
|
7edb9f8164 | ||
|
|
ff814167c6 | ||
|
|
d08d8dde64 | ||
|
|
5da441ceed | ||
|
|
22e646a68a | ||
|
|
d0ab20479f |
@@ -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";
|
||||
|
||||
@@ -19,6 +19,7 @@ const baseEntry = {
|
||||
pref_disable_polling: false,
|
||||
disabled_by: null,
|
||||
reason: null,
|
||||
error_reason_translation_domain: null,
|
||||
error_reason_translation_key: null,
|
||||
error_reason_translation_placeholders: null,
|
||||
};
|
||||
|
||||
@@ -424,6 +424,23 @@ const SCHEMAS: {
|
||||
},
|
||||
},
|
||||
},
|
||||
device_class: {
|
||||
name: "Device Class",
|
||||
selector: {
|
||||
device_class: {
|
||||
domain: "sensor",
|
||||
},
|
||||
},
|
||||
},
|
||||
device_class_multiple: {
|
||||
name: "Device Class (Multiple)",
|
||||
selector: {
|
||||
device_class: {
|
||||
domain: "binary_sensor",
|
||||
multiple: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
select_custom: {
|
||||
name: "Select (Custom)",
|
||||
selector: {
|
||||
|
||||
@@ -39,6 +39,7 @@ const createConfigEntry = (
|
||||
pref_disable_new_entities: false,
|
||||
pref_disable_polling: false,
|
||||
reason: null,
|
||||
error_reason_translation_domain: null,
|
||||
error_reason_translation_key: null,
|
||||
error_reason_translation_placeholders: null,
|
||||
...override,
|
||||
|
||||
+6
-6
@@ -94,7 +94,7 @@
|
||||
"color-name": "2.1.1",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.50.0",
|
||||
"cropperjs": "1.6.2",
|
||||
"cropperjs": "1.6.3",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.4.0",
|
||||
"deep-clone-simple": "1.1.1",
|
||||
@@ -143,7 +143,7 @@
|
||||
"@babel/helper-define-polyfill-provider": "1.0.0",
|
||||
"@babel/plugin-transform-runtime": "8.0.1",
|
||||
"@babel/preset-env": "8.0.2",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.2",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.3",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@html-eslint/eslint-plugin": "0.65.0",
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260729.0"
|
||||
version = "20260826.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -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
|
||||
@@ -33,7 +33,10 @@ const normalizeFilterArray = <T>(
|
||||
};
|
||||
|
||||
export const generateEntityFilter = (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<
|
||||
HomeAssistant,
|
||||
"states" | "entities" | "devices" | "areas" | "floors"
|
||||
>,
|
||||
filter: EntityFilter
|
||||
): EntityFilterFunc => {
|
||||
const domains = filter.domain
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -10,12 +10,16 @@ interface MeanFrame {
|
||||
}
|
||||
|
||||
interface MinMaxFrame {
|
||||
// A frame can hold a gap marker before any value lands in it, so the min/max
|
||||
// slots below only mean something once this is true.
|
||||
hasValue: boolean;
|
||||
minPoint: Point;
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxPoint: Point;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
gapPoint: Point | undefined;
|
||||
}
|
||||
|
||||
const SECOND = 1000;
|
||||
@@ -49,6 +53,25 @@ function snapFrameSize(step: number): number {
|
||||
return snapped;
|
||||
}
|
||||
|
||||
// y is NaN for a frame seeded by a gap marker, which has no value yet.
|
||||
function newFrame(
|
||||
point: Point,
|
||||
x: number,
|
||||
y: number,
|
||||
gapPoint: Point | undefined
|
||||
): MinMaxFrame {
|
||||
return {
|
||||
hasValue: gapPoint === undefined,
|
||||
minPoint: point,
|
||||
minX: x,
|
||||
minY: y,
|
||||
maxPoint: point,
|
||||
maxX: x,
|
||||
maxY: y,
|
||||
gapPoint,
|
||||
};
|
||||
}
|
||||
|
||||
export function downSampleLineData<
|
||||
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
|
||||
>(
|
||||
@@ -82,7 +105,10 @@ export function downSampleLineData<
|
||||
const pointData = getPointData(point);
|
||||
if (!Array.isArray(pointData)) continue;
|
||||
const x = Number(pointData[0]);
|
||||
const y = Number(pointData[1]);
|
||||
const rawY = pointData[1] as number | null;
|
||||
// Number(null) is 0, which would drag the mean towards zero
|
||||
if (rawY === null) continue;
|
||||
const y = Number(rawY);
|
||||
if (isNaN(x) || isNaN(y)) continue;
|
||||
|
||||
const frameIndex = Math.floor(x / step);
|
||||
@@ -120,21 +146,34 @@ export function downSampleLineData<
|
||||
const pointData = getPointData(point);
|
||||
if (!Array.isArray(pointData)) continue;
|
||||
const x = Number(pointData[0]);
|
||||
const y = Number(pointData[1]);
|
||||
if (isNaN(x) || isNaN(y)) continue;
|
||||
if (isNaN(x)) continue;
|
||||
const rawY = pointData[1] as number | null;
|
||||
if (rawY === null) {
|
||||
// The chart data modules push a null value to break the line where an
|
||||
// entity was unavailable. Number(null) is 0, so such a marker must stay
|
||||
// out of the comparisons below, where it would win the minimum slot
|
||||
// whenever the readings are positive and discard the frame's real
|
||||
// minimum. One marker per frame is enough to break the line, and keeping
|
||||
// them all would blow up the output on series that are mostly null. The
|
||||
// last one wins: where the break lands only depends on which points it
|
||||
// sits between, not on its own x.
|
||||
const gapIndex = Math.floor(x / step);
|
||||
const gapFrame = frames.get(gapIndex);
|
||||
if (gapFrame) {
|
||||
gapFrame.gapPoint = point;
|
||||
} else {
|
||||
frames.set(gapIndex, newFrame(point, x, NaN, point));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const y = Number(rawY);
|
||||
if (isNaN(y)) continue;
|
||||
|
||||
const frameIndex = Math.floor(x / step);
|
||||
const frame = frames.get(frameIndex);
|
||||
if (!frame) {
|
||||
frames.set(frameIndex, {
|
||||
minPoint: point,
|
||||
minX: x,
|
||||
minY: y,
|
||||
maxPoint: point,
|
||||
maxX: x,
|
||||
maxY: y,
|
||||
});
|
||||
} else {
|
||||
frames.set(frameIndex, newFrame(point, x, y, undefined));
|
||||
} else if (frame.hasValue) {
|
||||
// Match the original strict-less / strict-greater comparisons so the
|
||||
// first occurrence wins on ties.
|
||||
if (y < frame.minY) {
|
||||
@@ -147,18 +186,40 @@ export function downSampleLineData<
|
||||
frame.maxX = x;
|
||||
frame.maxY = y;
|
||||
}
|
||||
} else {
|
||||
// the frame held nothing but a marker so far
|
||||
frame.hasValue = true;
|
||||
frame.minPoint = point;
|
||||
frame.minX = x;
|
||||
frame.minY = y;
|
||||
frame.maxPoint = point;
|
||||
frame.maxX = x;
|
||||
frame.maxY = y;
|
||||
}
|
||||
}
|
||||
|
||||
const result: T[] = [];
|
||||
for (const frame of frames.values()) {
|
||||
// The order of the data must be preserved so max may be before min
|
||||
if (frame.minX > frame.maxX) {
|
||||
result.push(frame.maxPoint as T);
|
||||
if (frame.hasValue) {
|
||||
// The order of the data must be preserved so max may be before min
|
||||
if (frame.minX > frame.maxX) {
|
||||
result.push(frame.maxPoint as T);
|
||||
}
|
||||
result.push(frame.minPoint as T);
|
||||
if (frame.minX < frame.maxX) {
|
||||
result.push(frame.maxPoint as T);
|
||||
}
|
||||
}
|
||||
result.push(frame.minPoint as T);
|
||||
if (frame.minX < frame.maxX) {
|
||||
result.push(frame.maxPoint as T);
|
||||
if (frame.gapPoint !== undefined) {
|
||||
// A marker followed by a value in its own frame is a gap that closed
|
||||
// within one frame, which is about one device pixel: too narrow to show.
|
||||
// The kept points are exactly min and max, so comparing against the
|
||||
// later of the two catches that without any work on the ingest path. A
|
||||
// marker-only frame compares against its own x and always passes.
|
||||
const lastValueX = frame.minX > frame.maxX ? frame.minX : frame.maxX;
|
||||
if (Number(getPointData(frame.gapPoint)[0]) >= lastValueX) {
|
||||
result.push(frame.gapPoint as T);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -11,8 +11,9 @@ import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import { fullEntitiesContext } from "../../data/context";
|
||||
import type { DeviceAutomation } from "../../data/device/device_automation";
|
||||
import {
|
||||
deviceAutomationExtraConfig,
|
||||
deviceAutomationsEqual,
|
||||
deviceAutomationsSimilar,
|
||||
findEquivalentDeviceAutomation,
|
||||
sortDeviceAutomations,
|
||||
} from "../../data/device/device_automation";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
@@ -202,29 +203,40 @@ export abstract class HaDeviceAutomationPicker<
|
||||
: // No device, clear the list of automations
|
||||
[];
|
||||
|
||||
// If there is no value, or if we have changed the device ID, reset the
|
||||
// value. When the device changed (for example after replacing a removed
|
||||
// device), try to keep the same automation type/subtype on the new device
|
||||
// before falling back to the first available automation.
|
||||
if (!this.value || this.value.device_id !== this.deviceId) {
|
||||
const equivalent =
|
||||
this.value && this.deviceId
|
||||
? this._automations.find((automation) =>
|
||||
deviceAutomationsSimilar(automation, this.value!)
|
||||
)
|
||||
: undefined;
|
||||
this._setValue(
|
||||
equivalent ||
|
||||
(this._automations.length
|
||||
? this._automations[0]
|
||||
: this._createNoAutomation(this.deviceId))
|
||||
);
|
||||
this._updateValueForDevice();
|
||||
}
|
||||
this._renderEmpty = true;
|
||||
await this.updateComplete;
|
||||
this._renderEmpty = false;
|
||||
}
|
||||
|
||||
// The current value belongs to another device, either because there is no
|
||||
// value yet or because the device was just changed. Move it to the same
|
||||
// automation on the new device when there is one, otherwise start over.
|
||||
private _updateValueForDevice() {
|
||||
if (this.deviceId && this.value) {
|
||||
const equivalent = findEquivalentDeviceAutomation(
|
||||
this._entityReg,
|
||||
this._automations!,
|
||||
this.value
|
||||
);
|
||||
if (equivalent) {
|
||||
this._setValue({
|
||||
...equivalent,
|
||||
...deviceAutomationExtraConfig(this.value),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this._setValue(
|
||||
this._automations!.length
|
||||
? this._automations![0]
|
||||
: this._createNoAutomation(this.deviceId)
|
||||
);
|
||||
}
|
||||
|
||||
private _automationChanged(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
|
||||
+38
-15
@@ -1,34 +1,57 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiDelete } from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/entity/state-badge";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-settings-row";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
|
||||
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "./state-badge";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-settings-row";
|
||||
import {
|
||||
internationalizationContext,
|
||||
registriesContext,
|
||||
} from "../../data/context";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"delete-favorite-entity": { index: number };
|
||||
}
|
||||
interface HTMLElementTagNameMap {
|
||||
"home-favorite-entity-list-item": HomeFavoriteEntityListItem;
|
||||
"ha-favorite-entity-list-item": HaFavoriteEntityListItem;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("home-favorite-entity-list-item")
|
||||
export class HomeFavoriteEntityListItem extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@customElement("ha-favorite-entity-list-item")
|
||||
export class HaFavoriteEntityListItem extends LitElement {
|
||||
@property({ attribute: "entity-id" }) public entityId!: string;
|
||||
|
||||
@property({ type: Number }) public index = 0;
|
||||
|
||||
@state()
|
||||
@consumeEntityState({ entityIdPath: ["entityId"] })
|
||||
private _stateObj?: HassEntity;
|
||||
|
||||
@state()
|
||||
@consume({ context: registriesContext, subscribe: true })
|
||||
private _registries!: ContextType<typeof registriesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
protected render() {
|
||||
const stateObj = this.hass.states[this.entityId];
|
||||
const stateObj = this._stateObj;
|
||||
const { primary, secondary } = stateObj
|
||||
? computeEntityPickerDisplay(this.hass, stateObj)
|
||||
? computeEntityPickerDisplay(
|
||||
{
|
||||
...this._registries,
|
||||
language: this._i18n.language,
|
||||
translationMetadata: this._i18n.translationMetadata,
|
||||
},
|
||||
stateObj
|
||||
)
|
||||
: { primary: this.entityId, secondary: undefined };
|
||||
|
||||
return html`
|
||||
@@ -42,7 +65,7 @@ export class HomeFavoriteEntityListItem extends LitElement {
|
||||
}
|
||||
<ha-icon-button
|
||||
.path=${mdiDelete}
|
||||
.label=${this.hass.localize("ui.common.delete")}
|
||||
.label=${this._i18n.localize("ui.common.delete")}
|
||||
@click=${this._delete}
|
||||
></ha-icon-button>
|
||||
</ha-settings-row>
|
||||
+23
-21
@@ -2,24 +2,28 @@ import { mdiDragHorizontalVariant } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
|
||||
import "../../../components/entity/ha-entity-picker";
|
||||
import "../../../components/ha-sortable";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
|
||||
import "./home-favorite-entity-list-item";
|
||||
|
||||
@customElement("home-favorites-editor")
|
||||
export class HomeFavoritesEditor extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import type { HaEntityPicker } from "./ha-entity-picker";
|
||||
import "./ha-entity-picker";
|
||||
import "../ha-sortable";
|
||||
import "../ha-svg-icon";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import type { ValueChangedEvent } from "../../types";
|
||||
import "./ha-favorite-entity-list-item";
|
||||
|
||||
@customElement("ha-favorites-editor")
|
||||
export class HaFavoritesEditor extends LitElement {
|
||||
@property({ attribute: false }) public favorites: string[] = [];
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
|
||||
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
${this.label ? html`<p class="field-label">${this.label}</p>` : nothing}
|
||||
@@ -27,22 +31,21 @@ export class HomeFavoritesEditor extends LitElement {
|
||||
this.helper ? html`<p class="field-helper">${this.helper}</p>` : nothing
|
||||
}
|
||||
<ha-sortable handle-selector=".handle" @item-moved=${this._moved}>
|
||||
<div class="home-list">
|
||||
<div class="favorites-list">
|
||||
${repeat(
|
||||
this.favorites,
|
||||
(entityId) => entityId,
|
||||
(entityId, index) => html`
|
||||
<div class="home-list-item favorite-row">
|
||||
<div class="favorite-row">
|
||||
<div class="handle">
|
||||
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
|
||||
</div>
|
||||
<home-favorite-entity-list-item
|
||||
<ha-favorite-entity-list-item
|
||||
class="favorite-content"
|
||||
.hass=${this.hass}
|
||||
.entityId=${entityId}
|
||||
.index=${index}
|
||||
@delete-favorite-entity=${this._remove}
|
||||
></home-favorite-entity-list-item>
|
||||
></ha-favorite-entity-list-item>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
@@ -50,10 +53,9 @@ export class HomeFavoritesEditor extends LitElement {
|
||||
</ha-sortable>
|
||||
<ha-entity-picker
|
||||
add-button
|
||||
.addButtonLabel=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.strategy.home.add_favorite_entity"
|
||||
)}
|
||||
.addButtonLabel=${this.addButtonLabel}
|
||||
.excludeEntities=${this.favorites}
|
||||
.entityFilter=${this.entityFilter}
|
||||
@value-changed=${this._add}
|
||||
></ha-entity-picker>
|
||||
`;
|
||||
@@ -102,7 +104,7 @@ export class HomeFavoritesEditor extends LitElement {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 12px;
|
||||
}
|
||||
.home-list {
|
||||
.favorites-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -131,6 +133,6 @@ export class HomeFavoritesEditor extends LitElement {
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"home-favorites-editor": HomeFavoritesEditor;
|
||||
"ha-favorites-editor": HaFavoritesEditor;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { internationalizationContext } from "../data/context";
|
||||
import { DOMAIN_DEVICE_CLASSES } from "../data/device_classes";
|
||||
import { computeDeviceClassName } from "../data/entity/device_class";
|
||||
import type {
|
||||
HomeAssistantInternationalization,
|
||||
ValueChangedEvent,
|
||||
} from "../types";
|
||||
import "./chips/ha-chip-set";
|
||||
import "./chips/ha-input-chip";
|
||||
import "./ha-generic-picker";
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
|
||||
export const getDeviceClassOptions = (
|
||||
domain: string,
|
||||
localize: LocalizeFunc
|
||||
): PickerComboBoxItem[] =>
|
||||
(DOMAIN_DEVICE_CLASSES[domain] ?? []).map((deviceClass) => {
|
||||
const primary = computeDeviceClassName(localize, domain, deviceClass);
|
||||
return { id: deviceClass, primary, sorting_label: primary };
|
||||
});
|
||||
|
||||
@customElement("ha-device-class-picker")
|
||||
export class HaDeviceClassPicker extends LitElement {
|
||||
@property() public domain?: string;
|
||||
|
||||
@property({ attribute: false }) public value?: string | string[];
|
||||
|
||||
@property({ type: Boolean }) public multiple = false;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: HomeAssistantInternationalization;
|
||||
|
||||
private _loadedDomain?: string;
|
||||
|
||||
protected willUpdate() {
|
||||
if (!this.domain || !this._i18n || this._loadedDomain === this.domain) {
|
||||
return;
|
||||
}
|
||||
this._loadedDomain = this.domain;
|
||||
this._i18n.loadBackendTranslation("entity_component", this.domain);
|
||||
}
|
||||
|
||||
private get _value(): string[] {
|
||||
return this.value ? ensureArray(this.value) : [];
|
||||
}
|
||||
|
||||
private _deviceClassName(deviceClass: string): string {
|
||||
return this._i18n && this.domain
|
||||
? computeDeviceClassName(this._i18n.localize, this.domain, deviceClass)
|
||||
: deviceClass;
|
||||
}
|
||||
|
||||
private _options = memoizeOne(
|
||||
(
|
||||
domain: string | undefined,
|
||||
localize: LocalizeFunc | undefined
|
||||
): PickerComboBoxItem[] =>
|
||||
domain && localize ? getDeviceClassOptions(domain, localize) : []
|
||||
);
|
||||
|
||||
private _availableOptions = memoizeOne(
|
||||
(options: PickerComboBoxItem[], selected: string[]) =>
|
||||
options.filter((option) => !selected.includes(option.id))
|
||||
);
|
||||
|
||||
private _getItems = () => {
|
||||
const options = this._options(this.domain, this._i18n?.localize);
|
||||
return this.multiple
|
||||
? this._availableOptions(options, this._value)
|
||||
: options;
|
||||
};
|
||||
|
||||
private _valueRenderer = (value: string) =>
|
||||
html`<span slot="headline">${this._deviceClassName(value)}</span>`;
|
||||
|
||||
private _notFoundLabel = (search: string) => {
|
||||
const term = html`<b>'${search}'</b>`;
|
||||
return this._i18n
|
||||
? this._i18n.localize("ui.components.device-class-picker.no_match", {
|
||||
term,
|
||||
})
|
||||
: html`No device classes found for ${term}`;
|
||||
};
|
||||
|
||||
protected render() {
|
||||
const localize = this._i18n?.localize;
|
||||
const emptyLabel = localize?.(
|
||||
"ui.components.device-class-picker.no_device_classes"
|
||||
);
|
||||
|
||||
if (this.multiple) {
|
||||
const value = this._value;
|
||||
return html`
|
||||
${
|
||||
value.length
|
||||
? html`
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
value,
|
||||
(deviceClass) => deviceClass,
|
||||
(deviceClass) => {
|
||||
const label = this._deviceClassName(deviceClass);
|
||||
return html`
|
||||
<ha-input-chip
|
||||
.item=${deviceClass}
|
||||
.label=${label}
|
||||
.disabled=${this.disabled}
|
||||
@remove=${this._removeItem}
|
||||
selected
|
||||
>
|
||||
${label}
|
||||
</ha-input-chip>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</ha-chip-set>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<ha-generic-picker
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required && !value.length}
|
||||
.value=${""}
|
||||
.addButtonLabel=${
|
||||
this.label ?? localize?.("ui.components.device-class-picker.add")
|
||||
}
|
||||
.getItems=${this._getItems}
|
||||
.notFoundLabel=${this._notFoundLabel}
|
||||
.emptyLabel=${emptyLabel}
|
||||
@value-changed=${this._itemAdded}
|
||||
></ha-generic-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.label=${
|
||||
this.label ??
|
||||
localize?.("ui.components.device-class-picker.device_class")
|
||||
}
|
||||
.value=${this.value as string | undefined}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.getItems=${this._getItems}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
.notFoundLabel=${this._notFoundLabel}
|
||||
.emptyLabel=${emptyLabel}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-generic-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<string | undefined>) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "value-changed", { value: ev.detail.value || undefined });
|
||||
}
|
||||
|
||||
private _itemAdded(ev: ValueChangedEvent<string | undefined>) {
|
||||
ev.stopPropagation();
|
||||
const deviceClass = ev.detail.value;
|
||||
if (!deviceClass || this._value.includes(deviceClass)) {
|
||||
return;
|
||||
}
|
||||
this._setValue([...this._value, deviceClass]);
|
||||
}
|
||||
|
||||
private _removeItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
const deviceClass = (ev.currentTarget as HTMLElement & { item: string })
|
||||
.item;
|
||||
this._setValue(this._value.filter((item) => item !== deviceClass));
|
||||
}
|
||||
|
||||
private _setValue(value: string[]) {
|
||||
this.value = value;
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
ha-generic-picker {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
ha-chip-set {
|
||||
padding: 8px 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-device-class-picker": HaDeviceClassPicker;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { DeviceClassSelector } from "../../data/selector";
|
||||
import "../ha-device-class-picker";
|
||||
|
||||
@customElement("ha-selector-device_class")
|
||||
export class HaDeviceClassSelector extends LitElement {
|
||||
@property({ attribute: false }) public selector!: DeviceClassSelector;
|
||||
|
||||
@property() public value?: string | string[];
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-device-class-picker
|
||||
.domain=${this.selector.device_class?.domain}
|
||||
.value=${this.value}
|
||||
.multiple=${this.selector.device_class?.multiple ?? false}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
></ha-device-class-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-device-class-picker {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-selector-device_class": HaDeviceClassSelector;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ const LOAD_ELEMENTS = {
|
||||
date: () => import("./ha-selector-date"),
|
||||
datetime: () => import("./ha-selector-datetime"),
|
||||
device: () => import("./ha-selector-device"),
|
||||
device_class: () => import("./ha-selector-device-class"),
|
||||
duration: () => import("./ha-selector-duration"),
|
||||
entity: () => import("./ha-selector-entity"),
|
||||
entity_name: () => import("./ha-selector-entity-name"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -816,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,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { dump } from "js-yaml";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -7,8 +8,16 @@ import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_tim
|
||||
import type { Trigger } from "../../data/automation";
|
||||
import { migrateAutomationTrigger } from "../../data/automation";
|
||||
import { describeCondition, describeTrigger } from "../../data/automation_i18n";
|
||||
import { fullEntitiesContext, labelsContext } from "../../data/context";
|
||||
import type { ConditionDescriptions } from "../../data/condition";
|
||||
import {
|
||||
conditionDescriptionsContext,
|
||||
fullEntitiesContext,
|
||||
labelsContext,
|
||||
manifestsContext,
|
||||
triggerDescriptionsContext,
|
||||
} from "../../data/context";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import type { DomainManifestLookup } from "../../data/integration";
|
||||
import type { LabelRegistryEntry } from "../../data/label/label_registry";
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import { describeAction } from "../../data/script_i18n";
|
||||
@@ -17,7 +26,12 @@ import type {
|
||||
ChooseActionTraceStep,
|
||||
TraceExtended,
|
||||
} from "../../data/trace";
|
||||
import type { TargetSelector } from "../../data/selector";
|
||||
import { getDataFromPath, isTriggerPath } from "../../data/trace";
|
||||
import type { TriggerDescriptions } from "../../data/trigger";
|
||||
import { getDeviceTarget } from "../../panels/config/automation/target/get_device_target";
|
||||
import { getEntityTarget } from "../../panels/config/automation/target/get_entity_target";
|
||||
import "../../panels/config/automation/target/ha-automation-row-targets";
|
||||
import "../../panels/logbook/ha-logbook-renderer";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-alert";
|
||||
@@ -67,6 +81,18 @@ export class HaTracePathDetails extends LitElement {
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
_labelReg!: LabelRegistryEntry[];
|
||||
|
||||
@state()
|
||||
@consume({ context: manifestsContext, subscribe: true })
|
||||
private _manifests?: DomainManifestLookup;
|
||||
|
||||
@state()
|
||||
@consume({ context: triggerDescriptionsContext, subscribe: true })
|
||||
private _triggerDescriptions?: TriggerDescriptions;
|
||||
|
||||
@state()
|
||||
@consume({ context: conditionDescriptionsContext, subscribe: true })
|
||||
private _conditionDescriptions?: ConditionDescriptions;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="padded-box trace-info">
|
||||
@@ -191,51 +217,8 @@ export class HaTracePathDetails extends LitElement {
|
||||
)}`;
|
||||
}
|
||||
|
||||
const selectedType = this.selected.type;
|
||||
|
||||
return html`
|
||||
${
|
||||
curPath === this.selected.path
|
||||
? currentDetail.alias
|
||||
? html`<h2>${currentDetail.alias}</h2>`
|
||||
: selectedType === "trigger"
|
||||
? html`<h2>
|
||||
${describeTrigger(
|
||||
migrateAutomationTrigger({
|
||||
...currentDetail,
|
||||
}) as Trigger,
|
||||
this.hass,
|
||||
this._entityReg
|
||||
)}
|
||||
</h2>`
|
||||
: selectedType === "condition"
|
||||
? html`<h2>
|
||||
${describeCondition(
|
||||
currentDetail,
|
||||
this.hass,
|
||||
this._entityReg
|
||||
)}
|
||||
</h2>`
|
||||
: selectedType === "action"
|
||||
? html`<h2>
|
||||
${describeAction(
|
||||
this.hass,
|
||||
this._entityReg,
|
||||
currentDetail
|
||||
)}
|
||||
</h2>`
|
||||
: selectedType === "chooseOption"
|
||||
? html`<h2>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.actions.type.choose.option",
|
||||
{ number: pathParts[pathParts.length - 1] }
|
||||
)}
|
||||
</h2>`
|
||||
: nothing
|
||||
: html`<h2>
|
||||
${curPath.substring(this.selected.path.length + 1)}
|
||||
</h2>`
|
||||
}
|
||||
${this._renderStepHeading(curPath, currentDetail, pathParts)}
|
||||
${
|
||||
data.length === 1
|
||||
? nothing
|
||||
@@ -246,17 +229,7 @@ export class HaTracePathDetails extends LitElement {
|
||||
)}
|
||||
</h3>`
|
||||
}
|
||||
${
|
||||
curPath
|
||||
.substring(this.selected.path.length + 1)
|
||||
.includes("condition")
|
||||
? html`[${describeCondition(
|
||||
currentDetail,
|
||||
this.hass,
|
||||
this._entityReg
|
||||
)}]<br />`
|
||||
: nothing
|
||||
}
|
||||
${this._renderNestedCondition(curPath, currentDetail)}
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.automation.trace.path.executed",
|
||||
{
|
||||
@@ -324,6 +297,130 @@ export class HaTracePathDetails extends LitElement {
|
||||
return parts;
|
||||
}
|
||||
|
||||
private _renderStepHeading(
|
||||
curPath: string,
|
||||
currentDetail: any,
|
||||
pathParts: string[]
|
||||
) {
|
||||
if (curPath !== this.selected.path) {
|
||||
return html`<div class="heading">
|
||||
<h2>${curPath.substring(this.selected.path.length + 1)}</h2>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const selectedType = this.selected.type;
|
||||
|
||||
const description = currentDetail.alias
|
||||
? currentDetail.alias
|
||||
: selectedType === "trigger"
|
||||
? describeTrigger(
|
||||
migrateAutomationTrigger({ ...currentDetail }) as Trigger,
|
||||
this.hass,
|
||||
this._entityReg
|
||||
)
|
||||
: selectedType === "condition"
|
||||
? describeCondition(currentDetail, this.hass, this._entityReg)
|
||||
: selectedType === "action"
|
||||
? describeAction(
|
||||
this.hass,
|
||||
this._entityReg,
|
||||
currentDetail,
|
||||
undefined,
|
||||
false,
|
||||
this._manifests
|
||||
)
|
||||
: selectedType === "chooseOption"
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.automation.editor.actions.type.choose.option",
|
||||
{ number: pathParts[pathParts.length - 1] }
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (description === undefined) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<div class="heading">
|
||||
<h2>${description}</h2>
|
||||
${this._renderTargets(currentDetail, selectedType)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderNestedCondition(curPath: string, currentDetail: any) {
|
||||
if (
|
||||
!curPath.substring(this.selected.path.length + 1).includes("condition")
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<div class="nested-condition">
|
||||
${describeCondition(currentDetail, this.hass, this._entityReg)}
|
||||
${this._renderTargets(currentDetail, "condition", "s")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderTargets(
|
||||
config: any,
|
||||
type: NodeInfo["type"],
|
||||
size: "s" | "m" = "m"
|
||||
) {
|
||||
const target = this._getTarget(config, type);
|
||||
if (!target) {
|
||||
return nothing;
|
||||
}
|
||||
const targetSpec = this._getTargetSelector(config, type);
|
||||
return html`<div class="targets">
|
||||
<ha-automation-row-targets
|
||||
.target=${target}
|
||||
.selector=${targetSpec ? { target: targetSpec } : undefined}
|
||||
.size=${size}
|
||||
interactive
|
||||
></ha-automation-row-targets>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _getTargetSelector(
|
||||
config: any,
|
||||
type: NodeInfo["type"]
|
||||
): TargetSelector["target"] | undefined {
|
||||
if (type === "trigger") {
|
||||
return this._triggerDescriptions?.[config.trigger]?.target;
|
||||
}
|
||||
if (type === "condition") {
|
||||
return this._conditionDescriptions?.[config.condition]?.target;
|
||||
}
|
||||
if (type === "action" && typeof config.action === "string") {
|
||||
const [domain, service] = config.action.split(".", 2);
|
||||
return this.hass.services?.[domain]?.[service]?.target;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _getTarget(
|
||||
config: any,
|
||||
type: NodeInfo["type"]
|
||||
): HassServiceTarget | undefined {
|
||||
if (config.target) {
|
||||
return config.target;
|
||||
}
|
||||
if (type === "trigger" || type === "condition") {
|
||||
const element = type === "trigger" ? config.trigger : config.condition;
|
||||
if (element === "state" || element === "numeric_state") {
|
||||
return getEntityTarget(config.entity_id);
|
||||
}
|
||||
if (element === "device") {
|
||||
return getDeviceTarget(config.device_id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (type === "action") {
|
||||
return config.entity_id
|
||||
? getEntityTarget(config.entity_id)
|
||||
: getDeviceTarget(config.device_id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _renderSelectedConfig() {
|
||||
if (!this.selected?.path) {
|
||||
return nothing;
|
||||
@@ -463,6 +560,42 @@ export class HaTracePathDetails extends LitElement {
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-2);
|
||||
margin: var(--ha-space-4) 0;
|
||||
}
|
||||
|
||||
.heading h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.heading .targets {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.targets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-2);
|
||||
margin-top: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.nested-condition {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-2);
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.nested-condition .targets {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
+35
-223
@@ -149,9 +149,6 @@ const formatNumericLimitValue = (
|
||||
export interface DescribeOptions {
|
||||
// Skip the user defined alias and describe the underlying config.
|
||||
ignoreAlias?: boolean;
|
||||
// Leave the entities out of the sentence, for rows that render them as
|
||||
// target badges.
|
||||
hideEntities?: boolean;
|
||||
}
|
||||
|
||||
export const describeTrigger = (
|
||||
@@ -210,8 +207,7 @@ const tryDescribeTrigger = (
|
||||
const description = describeLegacyTrigger(
|
||||
trigger as LegacyTrigger,
|
||||
hass,
|
||||
entityRegistry,
|
||||
options?.hideEntities
|
||||
entityRegistry
|
||||
);
|
||||
|
||||
if (description) {
|
||||
@@ -235,8 +231,7 @@ const tryDescribeTrigger = (
|
||||
const describeLegacyTrigger = (
|
||||
trigger: LegacyTrigger,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
hideEntities = false
|
||||
entityRegistry: EntityRegistryEntry[]
|
||||
) => {
|
||||
// Event Trigger
|
||||
if (trigger.trigger === "event" && trigger.event_type) {
|
||||
@@ -267,12 +262,7 @@ const describeLegacyTrigger = (
|
||||
}
|
||||
|
||||
// Numeric State Trigger
|
||||
if (
|
||||
trigger.trigger === "numeric_state" &&
|
||||
(trigger.entity_id || hideEntities)
|
||||
) {
|
||||
const states = hass.states;
|
||||
|
||||
if (trigger.trigger === "numeric_state") {
|
||||
const stateObj = Array.isArray(trigger.entity_id)
|
||||
? hass.states[trigger.entity_id[0]]
|
||||
: (hass.states[trigger.entity_id] as HassEntity | undefined);
|
||||
@@ -292,82 +282,23 @@ const describeLegacyTrigger = (
|
||||
? describeDuration(hass.locale, trigger.for)
|
||||
: undefined;
|
||||
|
||||
if (hideEntities) {
|
||||
const suffix = numericThresholdSuffix(trigger);
|
||||
if (!suffix) {
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.label`
|
||||
);
|
||||
const suffix = numericThresholdSuffix(trigger);
|
||||
if (!suffix) {
|
||||
return hass.localize(`${triggerTranslationBaseKey}.numeric_state.label`);
|
||||
}
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.description.crossed_${suffix}`,
|
||||
{
|
||||
attribute: attribute,
|
||||
above: formatNumericLimitValue(hass, trigger.above),
|
||||
below: formatNumericLimitValue(hass, trigger.below),
|
||||
duration: duration,
|
||||
}
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.description.crossed_${suffix}`,
|
||||
{
|
||||
attribute: attribute,
|
||||
above: formatNumericLimitValue(hass, trigger.above),
|
||||
below: formatNumericLimitValue(hass, trigger.below),
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const entities: string[] = [];
|
||||
if (Array.isArray(trigger.entity_id)) {
|
||||
for (const entity of trigger.entity_id.values()) {
|
||||
if (states[entity]) {
|
||||
entities.push(computeStateName(states[entity]) || entity);
|
||||
}
|
||||
}
|
||||
} else if (trigger.entity_id) {
|
||||
entities.push(
|
||||
states[trigger.entity_id]
|
||||
? computeStateName(states[trigger.entity_id])
|
||||
: trigger.entity_id
|
||||
);
|
||||
}
|
||||
|
||||
if (trigger.above !== undefined && trigger.below !== undefined) {
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.description.above-below`,
|
||||
{
|
||||
attribute: attribute,
|
||||
entity: formatListWithOrs(hass.locale, entities),
|
||||
numberOfEntities: entities.length,
|
||||
above: formatNumericLimitValue(hass, trigger.above),
|
||||
below: formatNumericLimitValue(hass, trigger.below),
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (trigger.above !== undefined) {
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.description.above`,
|
||||
{
|
||||
attribute: attribute,
|
||||
entity: formatListWithOrs(hass.locale, entities),
|
||||
numberOfEntities: entities.length,
|
||||
above: formatNumericLimitValue(hass, trigger.above),
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (trigger.below !== undefined) {
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.description.below`,
|
||||
{
|
||||
attribute: attribute,
|
||||
entity: formatListWithOrs(hass.locale, entities),
|
||||
numberOfEntities: entities.length,
|
||||
below: formatNumericLimitValue(hass, trigger.below),
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// State Trigger
|
||||
if (trigger.trigger === "state") {
|
||||
const states = hass.states;
|
||||
|
||||
const entityArray: string[] = ensureArray(trigger.entity_id);
|
||||
|
||||
const stateObj = hass.states[entityArray?.[0]] as HassEntity | undefined;
|
||||
@@ -463,39 +394,12 @@ const describeLegacyTrigger = (
|
||||
duration = describeDuration(hass.locale, trigger.for) ?? "";
|
||||
}
|
||||
|
||||
if (hideEntities) {
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.state.description.changed`,
|
||||
{
|
||||
hasAttribute: attribute !== "" ? "true" : "false",
|
||||
attribute: attribute,
|
||||
anyChange: toChoice === "special" ? "true" : "false",
|
||||
fromChoice: fromChoice,
|
||||
fromString: fromString,
|
||||
toChoice: toChoice,
|
||||
toString: toString,
|
||||
hasDuration: duration !== "" ? "true" : "false",
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const entities: string[] = [];
|
||||
if (entityArray) {
|
||||
for (const entity of entityArray) {
|
||||
if (states[entity]) {
|
||||
entities.push(computeStateName(states[entity]) || entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.state.description.full`,
|
||||
`${triggerTranslationBaseKey}.state.description.changed`,
|
||||
{
|
||||
hasAttribute: attribute !== "" ? "true" : "false",
|
||||
attribute: attribute,
|
||||
hasEntity: entities.length !== 0 ? "true" : "false",
|
||||
entity: formatListWithOrs(hass.locale, entities),
|
||||
anyChange: toChoice === "special" ? "true" : "false",
|
||||
fromChoice: fromChoice,
|
||||
fromString: fromString,
|
||||
toChoice: toChoice,
|
||||
@@ -1037,8 +941,7 @@ const tryDescribeCondition = (
|
||||
const description = describeLegacyCondition(
|
||||
condition as LegacyCondition,
|
||||
hass,
|
||||
entityRegistry,
|
||||
options?.hideEntities
|
||||
entityRegistry
|
||||
);
|
||||
|
||||
if (description) {
|
||||
@@ -1064,8 +967,7 @@ const tryDescribeCondition = (
|
||||
const describeLegacyCondition = (
|
||||
condition: LegacyCondition,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
hideEntities = false
|
||||
entityRegistry: EntityRegistryEntry[]
|
||||
) => {
|
||||
if (condition.condition === "or") {
|
||||
const conditions = ensureArray(condition.conditions);
|
||||
@@ -1122,12 +1024,6 @@ const describeLegacyCondition = (
|
||||
|
||||
// State Condition
|
||||
if (condition.condition === "state") {
|
||||
if (!condition.entity_id && !hideEntities) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.state.description.no_entity`
|
||||
);
|
||||
}
|
||||
|
||||
const stateObj = hass.states[
|
||||
Array.isArray(condition.entity_id)
|
||||
? condition.entity_id[0]
|
||||
@@ -1184,51 +1080,14 @@ const describeLegacyCondition = (
|
||||
duration = describeDuration(hass.locale, condition.for) || "";
|
||||
}
|
||||
|
||||
if (hideEntities) {
|
||||
if (states.length === 0) {
|
||||
return hass.localize(`${conditionsTranslationBaseKey}.state.label`);
|
||||
}
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.state.description.is`,
|
||||
{
|
||||
hasAttribute: attribute !== "" ? "true" : "false",
|
||||
attribute: attribute,
|
||||
states: formatListWithOrs(hass.locale, states),
|
||||
hasDuration: duration !== "" ? "true" : "false",
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
if (states.length === 0) {
|
||||
return hass.localize(`${conditionsTranslationBaseKey}.state.label`);
|
||||
}
|
||||
|
||||
const entities: string[] = [];
|
||||
if (Array.isArray(condition.entity_id)) {
|
||||
for (const entity of condition.entity_id.values()) {
|
||||
if (hass.states[entity]) {
|
||||
entities.push(computeStateName(hass.states[entity]) || entity);
|
||||
}
|
||||
}
|
||||
} else if (condition.entity_id) {
|
||||
entities.push(
|
||||
hass.states[condition.entity_id]
|
||||
? computeStateName(hass.states[condition.entity_id])
|
||||
: condition.entity_id
|
||||
);
|
||||
}
|
||||
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.state.description.full`,
|
||||
`${conditionsTranslationBaseKey}.state.description.is`,
|
||||
{
|
||||
hasAttribute: attribute !== "" ? "true" : "false",
|
||||
attribute: attribute,
|
||||
numberOfEntities: entities.length,
|
||||
// With "any", entities are joined with "or", which takes a singular
|
||||
// verb in English even for multiple entities ("A or B is ...").
|
||||
matchAny: condition.match === "any" ? "true" : "false",
|
||||
entities:
|
||||
condition.match === "any"
|
||||
? formatListWithOrs(hass.locale, entities)
|
||||
: formatListWithAnds(hass.locale, entities),
|
||||
numberOfStates: states.length,
|
||||
states: formatListWithOrs(hass.locale, states),
|
||||
hasDuration: duration !== "" ? "true" : "false",
|
||||
duration: duration,
|
||||
@@ -1237,10 +1096,7 @@ const describeLegacyCondition = (
|
||||
}
|
||||
|
||||
// Numeric State Condition
|
||||
if (
|
||||
condition.condition === "numeric_state" &&
|
||||
(condition.entity_id || hideEntities)
|
||||
) {
|
||||
if (condition.condition === "numeric_state") {
|
||||
const entity_ids = condition.entity_id
|
||||
? ensureArray(condition.entity_id)
|
||||
: [];
|
||||
@@ -1257,64 +1113,20 @@ const describeLegacyCondition = (
|
||||
: condition.attribute
|
||||
: undefined;
|
||||
|
||||
if (hideEntities) {
|
||||
const suffix = numericThresholdSuffix(condition);
|
||||
if (!suffix) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.label`
|
||||
);
|
||||
const suffix = numericThresholdSuffix(condition);
|
||||
if (!suffix) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.label`
|
||||
);
|
||||
}
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.description.is_${suffix}`,
|
||||
{
|
||||
attribute,
|
||||
above: formatNumericLimitValue(hass, condition.above),
|
||||
below: formatNumericLimitValue(hass, condition.below),
|
||||
}
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.description.is_${suffix}`,
|
||||
{
|
||||
attribute,
|
||||
above: formatNumericLimitValue(hass, condition.above),
|
||||
below: formatNumericLimitValue(hass, condition.below),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const entity = formatListWithAnds(
|
||||
hass.locale,
|
||||
entity_ids.map((id) =>
|
||||
hass.states[id] ? computeStateName(hass.states[id]) : id || ""
|
||||
)
|
||||
);
|
||||
|
||||
if (condition.above !== undefined && condition.below !== undefined) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.description.above-below`,
|
||||
{
|
||||
attribute,
|
||||
entity,
|
||||
numberOfEntities: entity_ids.length,
|
||||
above: formatNumericLimitValue(hass, condition.above),
|
||||
below: formatNumericLimitValue(hass, condition.below),
|
||||
}
|
||||
);
|
||||
}
|
||||
if (condition.above !== undefined) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.description.above`,
|
||||
{
|
||||
attribute,
|
||||
entity,
|
||||
numberOfEntities: entity_ids.length,
|
||||
above: formatNumericLimitValue(hass, condition.above),
|
||||
}
|
||||
);
|
||||
}
|
||||
if (condition.below !== undefined) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.description.below`,
|
||||
{
|
||||
attribute,
|
||||
entity,
|
||||
numberOfEntities: entity_ids.length,
|
||||
below: formatNumericLimitValue(hass, condition.below),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Time condition
|
||||
|
||||
+38
-5
@@ -3,10 +3,16 @@ import type { HomeAssistant } from "../types";
|
||||
|
||||
type StrictConnectionMode = "disabled" | "guard_page" | "drop_connection";
|
||||
|
||||
export interface CloudAutoLogin {
|
||||
email: string;
|
||||
failed: string | null;
|
||||
}
|
||||
|
||||
interface CloudStatusNotLoggedIn {
|
||||
logged_in: false;
|
||||
cloud: "disconnected" | "connecting" | "connected";
|
||||
http_use_ssl: boolean;
|
||||
auto_login: CloudAutoLogin | null;
|
||||
}
|
||||
|
||||
export interface CertificateInformation {
|
||||
@@ -102,15 +108,15 @@ export interface CloudLoginMFA extends CloudLoginBase {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export type CloudEvent =
|
||||
| { type: "login" | "logout" | "auto_login_cancelled" }
|
||||
| { type: "auto_login_failed"; translation_key: string };
|
||||
|
||||
export const cloudLogin = ({
|
||||
hass,
|
||||
...rest
|
||||
}: CloudLoginPassword | CloudLoginMFA) =>
|
||||
hass.callApi<{ success: boolean; cloud_pipeline?: string }>(
|
||||
"POST",
|
||||
"cloud/login",
|
||||
rest
|
||||
);
|
||||
hass.callApi<{ success: boolean }>("POST", "cloud/login", rest);
|
||||
|
||||
export const cloudLogout = (hass: HomeAssistant) =>
|
||||
hass.callApi("POST", "cloud/logout");
|
||||
@@ -130,6 +136,33 @@ export const cloudRegister = (
|
||||
password,
|
||||
});
|
||||
|
||||
export const cloudRegisterAutoLogin = (
|
||||
hass: HomeAssistant,
|
||||
email: string,
|
||||
password: string
|
||||
) =>
|
||||
hass.callApi("POST", "cloud/register_auto_login", {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
export const attemptCloudAutoLoginNow = (hass: HomeAssistant) =>
|
||||
hass.callWS({ type: "cloud/attempt_auto_login_now" });
|
||||
|
||||
export const resendCloudAutoLoginConfirm = (hass: HomeAssistant) =>
|
||||
hass.callWS({ type: "cloud/resend_auto_login_confirm" });
|
||||
|
||||
export const cancelCloudAutoLogin = (hass: HomeAssistant) =>
|
||||
hass.callWS({ type: "cloud/cancel_auto_login" });
|
||||
|
||||
export const subscribeCloudEvents = (
|
||||
hass: HomeAssistant,
|
||||
callback: (event: CloudEvent) => void
|
||||
) =>
|
||||
hass.connection.subscribeMessage<CloudEvent>(callback, {
|
||||
type: "cloud/subscribe_events",
|
||||
});
|
||||
|
||||
export const cloudResendVerification = (hass: HomeAssistant, email: string) =>
|
||||
hass.callApi("POST", "cloud/resend_confirm", {
|
||||
email,
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface ConfigEntry {
|
||||
pref_disable_polling: boolean;
|
||||
disabled_by: "user" | null;
|
||||
reason: string | null;
|
||||
error_reason_translation_domain: string | null;
|
||||
error_reason_translation_key: string | null;
|
||||
error_reason_translation_placeholders: Record<string, string> | null;
|
||||
}
|
||||
|
||||
@@ -182,26 +182,70 @@ export const deviceAutomationEditorMode = (
|
||||
: "unknown-device";
|
||||
};
|
||||
|
||||
// Like deviceAutomationsEqual, but ignores device_id and entity_id so an
|
||||
// automation can be matched to the equivalent one on a different device (for
|
||||
// example when a referenced device was replaced by a split device).
|
||||
export const deviceAutomationsSimilar = (
|
||||
// Whether two device automations describe the same kind of automation, meaning
|
||||
// the same domain, type, subtype and event. Which device and which entity they
|
||||
// apply to is ignored, so an automation can be matched against the ones another
|
||||
// device offers.
|
||||
const deviceAutomationsSameType = (a: DeviceAutomation, b: DeviceAutomation) =>
|
||||
deviceAutomationIdentifiers
|
||||
.filter((property) => property !== "device_id" && property !== "entity_id")
|
||||
.every((property) => Object.is(a[property], b[property]));
|
||||
|
||||
// Whether two device automations point at the same entity. Both the entity
|
||||
// registry id and the entity id are accepted as reference, on either side.
|
||||
const deviceAutomationsSameEntity = (
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
a: DeviceAutomation,
|
||||
b: DeviceAutomation
|
||||
) => {
|
||||
if (typeof a !== typeof b) {
|
||||
if (!a.entity_id && !b.entity_id) {
|
||||
return true;
|
||||
}
|
||||
if (!a.entity_id || !b.entity_id) {
|
||||
return false;
|
||||
}
|
||||
return deviceAutomationIdentifiers
|
||||
.filter((property) => property !== "device_id" && property !== "entity_id")
|
||||
.every((property) => {
|
||||
const inA = property in a;
|
||||
const inB = property in b;
|
||||
if (!inA && !inB) {
|
||||
return true;
|
||||
}
|
||||
return Object.is(a[property], b[property]);
|
||||
});
|
||||
return (
|
||||
a.entity_id === b.entity_id ||
|
||||
compareEntityIdWithEntityRegId(entityRegistry, a.entity_id, b.entity_id)
|
||||
);
|
||||
};
|
||||
|
||||
// Finds, among the automations a device offers, the one matching the given
|
||||
// automation, so a device automation can follow its device after the device was
|
||||
// replaced. A device exposes the same automation type once per entity, so
|
||||
// matching on the type alone picks an arbitrary entity. Splitting a device
|
||||
// leaves the entity registry ids untouched, which makes the entity the reliable
|
||||
// match, the type only serving as a fallback for entity-less automations.
|
||||
export const findEquivalentDeviceAutomation = <T extends DeviceAutomation>(
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
automations: T[],
|
||||
automation: DeviceAutomation
|
||||
): T | undefined => {
|
||||
const sameType = automations.filter((candidate) =>
|
||||
deviceAutomationsSameType(candidate, automation)
|
||||
);
|
||||
const sameEntity = sameType.find((candidate) =>
|
||||
deviceAutomationsSameEntity(entityRegistry, candidate, automation)
|
||||
);
|
||||
return sameEntity || sameType[0];
|
||||
};
|
||||
|
||||
// Everything the automation list does not return: the extra fields of the
|
||||
// capabilities schema (`for`, `above`, ...) and the config of the row holding
|
||||
// the automation (`enabled`, `id`, `alias`, ...).
|
||||
export const deviceAutomationExtraConfig = <T extends DeviceAutomation>(
|
||||
automation: T
|
||||
): Partial<T> => {
|
||||
const extraConfig: Partial<T> = {};
|
||||
for (const property in automation) {
|
||||
if (
|
||||
property !== "metadata" &&
|
||||
!deviceAutomationIdentifiers.includes(property)
|
||||
) {
|
||||
extraConfig[property] = automation[property];
|
||||
}
|
||||
}
|
||||
return extraConfig;
|
||||
};
|
||||
|
||||
const compareEntityIdWithEntityRegId = (
|
||||
|
||||
@@ -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,11 @@
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
|
||||
export const NO_DEVICE_CLASS = "none";
|
||||
|
||||
export const computeDeviceClassName = (
|
||||
localize: LocalizeFunc,
|
||||
domain: string,
|
||||
deviceClass: string
|
||||
): string =>
|
||||
localize(`component.${domain}.entity_component.${deviceClass}.name`) ||
|
||||
deviceClass;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { DOMAIN_DEVICE_CLASSES } from "../device_classes";
|
||||
import { 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);
|
||||
};
|
||||
};
|
||||
@@ -35,6 +35,18 @@ export interface HomeFrontendSystemData {
|
||||
shortcuts?: ShortcutItem[];
|
||||
}
|
||||
|
||||
export type SecurityAlertSeverity = "alert" | "warning";
|
||||
|
||||
export interface SecurityAlertEntityConfig {
|
||||
entity: string;
|
||||
severity?: SecurityAlertSeverity;
|
||||
}
|
||||
|
||||
export interface SecurityFrontendSystemData {
|
||||
alert_entities?: SecurityAlertEntityConfig[];
|
||||
favorite_entities?: string[];
|
||||
}
|
||||
|
||||
export interface EnergyFrontendSystemData {
|
||||
// Stable "<view>.<card-type>" keys of energy dashboard cards the user has
|
||||
// hidden. An absent key or array means nothing is hidden (all cards visible),
|
||||
@@ -51,6 +63,7 @@ declare global {
|
||||
core: CoreFrontendSystemData;
|
||||
home: HomeFrontendSystemData;
|
||||
energy: EnergyFrontendSystemData;
|
||||
security: SecurityFrontendSystemData;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { timeCacheEntityPromiseFunc } from "../common/util/time-cache-entity-promise-func";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { MediaPlayerItem, SearchMediaResult } from "./media-player";
|
||||
|
||||
@@ -7,7 +8,7 @@ export interface ResolvedMediaSource {
|
||||
}
|
||||
|
||||
export const resolveMediaSource = (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
media_content_id: string
|
||||
) =>
|
||||
hass.callWS<ResolvedMediaSource>({
|
||||
@@ -15,6 +16,23 @@ export const resolveMediaSource = (
|
||||
media_content_id,
|
||||
});
|
||||
|
||||
// Resolved URLs are signed and valid for 24 hours (CONTENT_AUTH_EXPIRY_TIME in
|
||||
// core). Resolving again returns a different signature, which would defeat the
|
||||
// browser cache, so reuse the resolved URL for just under its validity.
|
||||
export const RESOLVE_CACHE_TIME = 23 * 60 * 60 * 1000; // 23 hours
|
||||
|
||||
export const resolveMediaSourceWithCache = (
|
||||
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
|
||||
media_content_id: string
|
||||
): Promise<ResolvedMediaSource> =>
|
||||
timeCacheEntityPromiseFunc(
|
||||
"_resolvedMediaSource",
|
||||
RESOLVE_CACHE_TIME,
|
||||
resolveMediaSource,
|
||||
hass,
|
||||
media_content_id
|
||||
);
|
||||
|
||||
export const browseLocalMediaPlayer = (
|
||||
hass: HomeAssistant,
|
||||
mediaContentId?: string
|
||||
|
||||
@@ -48,6 +48,7 @@ export type Selector =
|
||||
| DeviceSelector
|
||||
| FloorSelector
|
||||
| LegacyDeviceSelector
|
||||
| DeviceClassSelector
|
||||
| DurationSelector
|
||||
| EntitySelector
|
||||
| EntityNameSelector
|
||||
@@ -484,6 +485,13 @@ export interface SelectSelector {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface DeviceClassSelector {
|
||||
device_class: {
|
||||
domain: string;
|
||||
multiple?: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface SelectorSelector {
|
||||
selector: {} | null;
|
||||
}
|
||||
|
||||
+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",
|
||||
];
|
||||
@@ -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,7 +73,10 @@ class HaMoreInfoDetails extends LitElement {
|
||||
stateEntries,
|
||||
attributes,
|
||||
yamlData: stateYamlData,
|
||||
} = this._getDetailData(this._stateObj);
|
||||
} = this._getDetailData(
|
||||
this._stateObj,
|
||||
this.hass.formatEntityAttributeName
|
||||
);
|
||||
const { floor, area, device } = getEntityContext(
|
||||
this._stateObj,
|
||||
this.hass.entities,
|
||||
@@ -211,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 }[];
|
||||
|
||||
@@ -1029,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,
|
||||
|
||||
@@ -62,9 +62,11 @@ import type {
|
||||
AutomationClipboard,
|
||||
Condition,
|
||||
} from "../../../../data/automation";
|
||||
import type { ConditionDescriptions } from "../../../../data/condition";
|
||||
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
|
||||
import { validateConfig } from "../../../../data/config";
|
||||
import {
|
||||
conditionDescriptionsContext,
|
||||
fullEntitiesContext,
|
||||
manifestsContext,
|
||||
} from "../../../../data/context";
|
||||
@@ -90,6 +92,8 @@ import { isMac } from "../../../../util/is_mac";
|
||||
import { showEditorToast } from "../editor-toast";
|
||||
import "../ha-automation-editor-warning";
|
||||
import { overflowStyles, rowStyles } from "../styles";
|
||||
import { getDeviceTarget } from "../target/get_device_target";
|
||||
import { getEntityTarget } from "../target/get_entity_target";
|
||||
import "../target/ha-automation-row-targets";
|
||||
import "./ha-automation-action-editor";
|
||||
import type HaAutomationActionEditor from "./ha-automation-action-editor";
|
||||
@@ -205,6 +209,10 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
@consume({ context: manifestsContext, subscribe: true })
|
||||
private _manifests?: DomainManifestLookup;
|
||||
|
||||
@state()
|
||||
@consume({ context: conditionDescriptionsContext, subscribe: true })
|
||||
private _conditionDescriptions?: ConditionDescriptions;
|
||||
|
||||
@state() private _running = false;
|
||||
|
||||
@state() private _runResult?: {
|
||||
@@ -292,13 +300,19 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
? this._extractTargets(this.action as ServiceAction)
|
||||
: type === "device_id" && (this.action as DeviceAction).device_id
|
||||
? { device_id: (this.action as DeviceAction).device_id }
|
||||
: undefined;
|
||||
: type === "condition"
|
||||
? this._extractConditionTarget(this.action as Condition)
|
||||
: undefined;
|
||||
|
||||
const serviceTargetSpec =
|
||||
type === "service" && action
|
||||
? this.hass.services?.[computeDomain(action)]?.[computeObjectId(action)]
|
||||
type === "condition"
|
||||
? this._conditionDescriptions?.[(this.action as Condition).condition]
|
||||
?.target
|
||||
: undefined;
|
||||
: type === "service" && action
|
||||
? this.hass.services?.[computeDomain(action)]?.[
|
||||
computeObjectId(action)
|
||||
]?.target
|
||||
: undefined;
|
||||
|
||||
const noteTooltipText = truncateWithEllipsis(
|
||||
this.action.note?.trim() || "",
|
||||
@@ -777,6 +791,28 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
return {};
|
||||
}
|
||||
|
||||
private _extractConditionTarget(
|
||||
condition: Condition
|
||||
): HassServiceTarget | undefined {
|
||||
if (typeof condition !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
if ("target" in condition && condition.target) {
|
||||
return condition.target;
|
||||
}
|
||||
if (
|
||||
(condition.condition === "state" ||
|
||||
condition.condition === "numeric_state") &&
|
||||
"entity_id" in condition
|
||||
) {
|
||||
return getEntityTarget(condition.entity_id);
|
||||
}
|
||||
if (condition.condition === "device" && "device_id" in condition) {
|
||||
return getDeviceTarget(condition.device_id as string);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _renderTargets = memoizeOne(
|
||||
(
|
||||
target?: HassServiceTarget,
|
||||
|
||||
@@ -226,9 +226,7 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
}
|
||||
<h3 slot="header">
|
||||
${capitalizeFirstLetter(
|
||||
describeCondition(this.condition, this.hass, this._entityReg, {
|
||||
hideEntities: true,
|
||||
})
|
||||
describeCondition(this.condition, this.hass, this._entityReg)
|
||||
)}
|
||||
${
|
||||
target !== undefined || targetRequired
|
||||
|
||||
@@ -68,6 +68,9 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
@property({ type: Boolean })
|
||||
public interactive = false;
|
||||
|
||||
@property({ reflect: true })
|
||||
public size: "s" | "m" = "m";
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
@@ -652,6 +655,23 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:host([size="s"]) {
|
||||
min-height: 24px;
|
||||
}
|
||||
:host([size="s"]) .target {
|
||||
height: 24px;
|
||||
}
|
||||
/* A default 24px icon would fill the whole small chip. */
|
||||
:host([size="s"]) .target ha-icon,
|
||||
:host([size="s"]) .target ha-svg-icon,
|
||||
:host([size="s"]) .target ha-domain-icon,
|
||||
:host([size="s"]) .target ha-floor-icon {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
:host([size="s"]) .target ha-floor-icon {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
button.target {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -251,9 +251,7 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
}
|
||||
<h3 slot="header">
|
||||
${capitalizeFirstLetter(
|
||||
describeTrigger(this.trigger, this.hass, this._entityReg, {
|
||||
hideEntities: true,
|
||||
})
|
||||
describeTrigger(this.trigger, this.hass, this._entityReg)
|
||||
)}
|
||||
${
|
||||
target !== undefined || targetRequired
|
||||
|
||||
@@ -21,14 +21,10 @@ import {
|
||||
completeCloudOnboarding,
|
||||
fetchCloudSubscriptionInfo,
|
||||
ONBOARDING_ITEMS,
|
||||
removeCloudData,
|
||||
} from "../../../../data/cloud";
|
||||
import type { Webhook } from "../../../../data/webhook";
|
||||
import { fetchWebhooks } from "../../../../data/webhook";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
} from "../../../../dialogs/generic/show-dialog-box";
|
||||
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../../layouts/hass-subpage";
|
||||
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
@@ -41,6 +37,7 @@ import {
|
||||
} from "./cloud-account-status";
|
||||
import { showCloudOnboardingDialog } from "./show-dialog-cloud-onboarding";
|
||||
import { showSupportPackageDialog } from "./show-dialog-cloud-support-package";
|
||||
import { confirmDeleteCloudData } from "../delete-cloud-data";
|
||||
|
||||
@customElement("cloud-account")
|
||||
export class CloudAccount extends SubscribeMixin(LitElement) {
|
||||
@@ -297,33 +294,7 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private async _deleteCloudData() {
|
||||
const confirm = await showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.reset_data_confirm_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.reset_data_confirm_text"
|
||||
),
|
||||
confirmText: this.hass.localize("ui.panel.config.cloud.account.reset"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await cloudLogout(this.hass);
|
||||
await removeCloudData(this.hass);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.reset_data_failed"
|
||||
),
|
||||
text: err?.message,
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
fireEvent(this, "ha-refresh-cloud-status");
|
||||
}
|
||||
await confirmDeleteCloudData(this, this.hass, { signOutFirst: true });
|
||||
}
|
||||
|
||||
private async _downloadSupportPackage() {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { showSupportPackageDialog } from "./account/show-dialog-cloud-support-package";
|
||||
import { confirmDeleteCloudData } from "./delete-cloud-data";
|
||||
|
||||
// Recovery actions for the signed-out cloud pages, which all need to reach them
|
||||
// — including while a registration is waiting on its email confirmation.
|
||||
@customElement("cloud-signed-out-menu")
|
||||
export class CloudSignedOutMenu extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-dropdown @wa-select=${this._handleMenuAction}>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
|
||||
<ha-dropdown-item value="reset">
|
||||
${this.hass.localize("ui.panel.config.cloud.account.reset_cloud_data")}
|
||||
<ha-svg-icon slot="icon" .path=${mdiDeleteForever}></ha-svg-icon>
|
||||
</ha-dropdown-item>
|
||||
<ha-dropdown-item value="download">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.download_support_package"
|
||||
)}
|
||||
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleMenuAction(ev: HaDropdownSelectEvent) {
|
||||
switch (ev.detail.item.value) {
|
||||
case "reset":
|
||||
this._deleteCloudData();
|
||||
break;
|
||||
case "download":
|
||||
showSupportPackageDialog(this);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async _deleteCloudData() {
|
||||
await confirmDeleteCloudData(this, this.hass);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"cloud-signed-out-menu": CloudSignedOutMenu;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { css } from "lit";
|
||||
|
||||
export const cloudSignedOutStyle = css`
|
||||
.content {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: calc(var(--safe-area-inset-bottom) + var(--ha-space-6));
|
||||
}
|
||||
ha-card {
|
||||
width: 100%;
|
||||
margin-bottom: 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);
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
text-wrap: balance;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { cloudLogout, removeCloudData } from "../../../data/cloud";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
} from "../../../dialogs/generic/show-dialog-box";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
// Confirms, then wipes every trace of the cloud account from this instance.
|
||||
// `signOutFirst` is for callers reached while signed in: the session has to go
|
||||
// before the data can.
|
||||
export const confirmDeleteCloudData = async (
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
{ signOutFirst = false }: { signOutFirst?: boolean } = {}
|
||||
): Promise<boolean> => {
|
||||
const confirm = await showConfirmationDialog(element, {
|
||||
title: hass.localize(
|
||||
"ui.panel.config.cloud.account.reset_data_confirm_title"
|
||||
),
|
||||
text: hass.localize(
|
||||
"ui.panel.config.cloud.account.reset_data_confirm_text"
|
||||
),
|
||||
confirmText: hass.localize("ui.panel.config.cloud.account.reset"),
|
||||
destructive: true,
|
||||
});
|
||||
|
||||
if (!confirm) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (signOutFirst) {
|
||||
await cloudLogout(hass);
|
||||
}
|
||||
await removeCloudData(hass);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(element, {
|
||||
title: hass.localize("ui.panel.config.cloud.account.reset_data_failed"),
|
||||
text: err?.message,
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
fireEvent(element, "ha-refresh-cloud-status");
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -169,4 +169,8 @@ declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"cloud-forgot-password-card": CloudForgotPasswordCard;
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"cloud-done": { flashMessage: string };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export class CloudForgotPassword extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
back-path="/config/cloud/login"
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.forgot_password.title"
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import type { CloudStatus } from "../../../data/cloud";
|
||||
import { cancelCloudAutoLogin } from "../../../data/cloud";
|
||||
import type { RouterOptions } from "../../../layouts/hass-router-page";
|
||||
import { HassRouterPage } from "../../../layouts/hass-router-page";
|
||||
import type { ValueChangedEvent, HomeAssistant, Route } from "../../../types";
|
||||
import "./account/cloud-account";
|
||||
import "./login/cloud-login-panel";
|
||||
import "./start/cloud-start";
|
||||
|
||||
const LOGGED_IN_URLS = [
|
||||
"account",
|
||||
@@ -18,7 +20,12 @@ const LOGGED_IN_URLS = [
|
||||
"webhooks",
|
||||
] as const;
|
||||
|
||||
const NOT_LOGGED_IN_URLS = ["login", "register", "forgot-password"] as const;
|
||||
const NOT_LOGGED_IN_URLS = [
|
||||
"start",
|
||||
"login",
|
||||
"register",
|
||||
"forgot-password",
|
||||
] as const;
|
||||
|
||||
type CloudPage =
|
||||
(typeof LOGGED_IN_URLS)[number] | (typeof NOT_LOGGED_IN_URLS)[number];
|
||||
@@ -36,7 +43,7 @@ class HaConfigCloud extends HassRouterPage {
|
||||
@property({ attribute: false }) public cloudStatus!: CloudStatus;
|
||||
|
||||
protected routerOptions: RouterOptions = {
|
||||
defaultPage: "login",
|
||||
defaultPage: "start",
|
||||
showLoading: true,
|
||||
initialLoad: () => this._cloudStatusLoaded,
|
||||
// Guard the different pages based on if we're logged in.
|
||||
@@ -45,14 +52,30 @@ class HaConfigCloud extends HassRouterPage {
|
||||
if (!LOGGED_IN_URLS.some((url) => url === page)) {
|
||||
return "account";
|
||||
}
|
||||
} else if (!NOT_LOGGED_IN_URLS.some((url) => url === page)) {
|
||||
return "login";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!NOT_LOGGED_IN_URLS.some((url) => url === page)) {
|
||||
return "start";
|
||||
}
|
||||
|
||||
if (
|
||||
page !== "register" &&
|
||||
this.cloudStatus.auto_login &&
|
||||
!this._autoLoginCancelled
|
||||
) {
|
||||
return "register";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
routes: {
|
||||
start: {
|
||||
tag: "cloud-start",
|
||||
},
|
||||
login: {
|
||||
tag: "cloud-login-panel",
|
||||
load: () => import("./login/cloud-login-panel"),
|
||||
},
|
||||
register: {
|
||||
tag: "cloud-register",
|
||||
@@ -96,6 +119,10 @@ class HaConfigCloud extends HassRouterPage {
|
||||
|
||||
@state() private _loginEmail = "";
|
||||
|
||||
private _autoLoginCancelled = false;
|
||||
|
||||
private _lastPage = "";
|
||||
|
||||
private _resolveCloudStatusLoaded!: () => void;
|
||||
|
||||
private _cloudStatusLoaded = new Promise<void>((resolve) => {
|
||||
@@ -122,6 +149,14 @@ class HaConfigCloud extends HassRouterPage {
|
||||
navigate(this.route.prefix, { replace: true });
|
||||
}
|
||||
}
|
||||
|
||||
// A flash belongs to the page it was routed to. Cleared here rather than in
|
||||
// beforeRender, which runs inside update() where assigning state schedules a
|
||||
// second cycle.
|
||||
if (this._lastPage === "login" && this._currentPage !== "login") {
|
||||
this._flashMessage = "";
|
||||
}
|
||||
this._lastPage = this._currentPage;
|
||||
}
|
||||
|
||||
protected createElement(tag: string) {
|
||||
@@ -132,9 +167,27 @@ class HaConfigCloud extends HassRouterPage {
|
||||
el.addEventListener("flash-message-changed", (ev) => {
|
||||
this._flashMessage = (ev as ValueChangedEvent<string>).detail.value;
|
||||
});
|
||||
el.addEventListener("cloud-cancel-auto-login", () => {
|
||||
this._autoLoginCancelled = true;
|
||||
this._cancelAutoLogin();
|
||||
});
|
||||
el.addEventListener("cloud-auto-login-started", () => {
|
||||
this._autoLoginCancelled = false;
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
private async _cancelAutoLogin() {
|
||||
try {
|
||||
await cancelCloudAutoLogin(this.hass);
|
||||
} catch (_err) {
|
||||
// Both callers are fire-and-forget navigations with nowhere to report
|
||||
// this; the refresh below still reconciles whatever the backend kept.
|
||||
} finally {
|
||||
fireEvent(this, "ha-refresh-cloud-status");
|
||||
}
|
||||
}
|
||||
|
||||
protected updatePageEl(el) {
|
||||
// We are not going to update if the current page if we are not logged in
|
||||
// and the current page requires being logged in. Happens when we log out.
|
||||
|
||||
@@ -1,28 +1,14 @@
|
||||
import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../../common/navigate";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
|
||||
import "../../../../components/ha-dropdown-item";
|
||||
import "../../../../components/ha-icon-next";
|
||||
import "../../../../components/ha-list";
|
||||
import "../../../../components/ha-list-item";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import { removeCloudData } from "../../../../data/cloud";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
} from "../../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import "../../ha-config-section";
|
||||
import { showSupportPackageDialog } from "../account/show-dialog-cloud-support-package";
|
||||
import { cloudSignedOutStyle } from "../cloud-signed-out-style";
|
||||
import { cloudSubpageStyle } from "../account/cloud-subpage-style";
|
||||
import "./cloud-login";
|
||||
import type { CloudLogin } from "./cloud-login";
|
||||
|
||||
@@ -30,212 +16,88 @@ import type { CloudLogin } from "./cloud-login";
|
||||
export class CloudLoginPanel extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property() public email?: string;
|
||||
|
||||
@property({ attribute: false }) public flashMessage?: string;
|
||||
|
||||
@query("cloud-login") private _cloudLoginElement!: CloudLogin;
|
||||
@query("cloud-login") private _cloudLoginElement?: CloudLogin;
|
||||
|
||||
protected firstUpdated(): void {
|
||||
this._focusEmail();
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
header="Home Assistant Cloud"
|
||||
back-path="/config/cloud/start"
|
||||
.header=${this.hass.localize("ui.panel.config.cloud.login.sign_in")}
|
||||
>
|
||||
<ha-dropdown slot="toolbar-icon" @wa-select=${this._handleMenuAction}>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
|
||||
<ha-dropdown-item value="reset">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.reset_cloud_data"
|
||||
)}
|
||||
<ha-svg-icon slot="icon" .path=${mdiDeleteForever}></ha-svg-icon>
|
||||
</ha-dropdown-item>
|
||||
<ha-dropdown-item value="download">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.download_support_package"
|
||||
)}
|
||||
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
<div class="content">
|
||||
<ha-config-section .isWide=${this.isWide}>
|
||||
<span slot="header">Home Assistant Cloud</span>
|
||||
<div slot="introduction">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.login.introduction"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.login.introduction2"
|
||||
)}
|
||||
<a
|
||||
href="https://www.nabucasa.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
${
|
||||
this.flashMessage
|
||||
? html`<ha-alert
|
||||
dismissable
|
||||
@alert-dismissed-clicked=${this._dismissFlash}
|
||||
>
|
||||
Nabu Casa, Inc</a
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.login.introduction2a"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.login.introduction3"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://www.nabucasa.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.login.learn_more_link"
|
||||
)}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
${
|
||||
this.flashMessage
|
||||
? html`<ha-alert
|
||||
dismissable
|
||||
@alert-dismissed-clicked=${this._dismissFlash}
|
||||
>
|
||||
${this.flashMessage}
|
||||
</ha-alert>`
|
||||
: ""
|
||||
}
|
||||
|
||||
<cloud-login
|
||||
.hass=${this.hass}
|
||||
.email=${this.email}
|
||||
.localize=${this.hass.localize}
|
||||
@cloud-forgot-password=${this._handleForgotPassword}
|
||||
check-connection
|
||||
></cloud-login>
|
||||
|
||||
<ha-card outlined>
|
||||
<ha-list>
|
||||
<ha-list-item @click=${this._handleRegister} twoline hasMeta>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.login.start_trial"
|
||||
)}
|
||||
<span slot="secondary">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.login.trial_info"
|
||||
)}
|
||||
</span>
|
||||
<ha-icon-next slot="meta"></ha-icon-next>
|
||||
</ha-list-item>
|
||||
</ha-list>
|
||||
</ha-card>
|
||||
</ha-config-section>
|
||||
${this.flashMessage}
|
||||
</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
<cloud-login
|
||||
.hass=${this.hass}
|
||||
.email=${this.email}
|
||||
.localize=${this.hass.localize}
|
||||
.lead=${this.hass.localize(
|
||||
"ui.panel.config.cloud.login.sign_in_lead"
|
||||
)}
|
||||
check-connection
|
||||
@cloud-forgot-password=${this._handleForgotPassword}
|
||||
></cloud-login>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _focusEmail() {
|
||||
const cloudLogin = this._cloudLoginElement;
|
||||
if (!cloudLogin) {
|
||||
return;
|
||||
}
|
||||
await cloudLogin.updateComplete;
|
||||
cloudLogin.emailField?.focus();
|
||||
}
|
||||
|
||||
private _handleForgotPassword() {
|
||||
this._dismissFlash();
|
||||
fireEvent(this, "cloud-email-changed", {
|
||||
value: this._cloudLoginElement.emailField.value ?? "",
|
||||
value: this._cloudLoginElement?.emailField?.value ?? this.email ?? "",
|
||||
});
|
||||
navigate("/config/cloud/forgot-password");
|
||||
}
|
||||
|
||||
private _handleRegister() {
|
||||
this._dismissFlash();
|
||||
|
||||
fireEvent(this, "cloud-email-changed", {
|
||||
value: this._cloudLoginElement.emailField.value ?? "",
|
||||
});
|
||||
navigate("/config/cloud/register");
|
||||
}
|
||||
|
||||
private _dismissFlash() {
|
||||
fireEvent(this, "flash-message-changed", { value: "" });
|
||||
}
|
||||
|
||||
private _handleMenuAction(ev: HaDropdownSelectEvent) {
|
||||
const value = ev.detail.item.value;
|
||||
switch (value) {
|
||||
case "reset":
|
||||
this._deleteCloudData();
|
||||
break;
|
||||
case "download":
|
||||
this._downloadSupportPackage();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async _deleteCloudData() {
|
||||
const confirm = await showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.reset_data_confirm_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.reset_data_confirm_text"
|
||||
),
|
||||
confirmText: this.hass.localize("ui.panel.config.cloud.account.reset"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await removeCloudData(this.hass);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.cloud.account.reset_data_failed"
|
||||
),
|
||||
text: err?.message,
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
fireEvent(this, "ha-refresh-cloud-status");
|
||||
}
|
||||
}
|
||||
|
||||
private async _downloadSupportPackage() {
|
||||
showSupportPackageDialog(this);
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
haStyle,
|
||||
cloudSubpageStyle,
|
||||
cloudSignedOutStyle,
|
||||
css`
|
||||
.content {
|
||||
padding-bottom: 24px;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
[slot="introduction"] {
|
||||
margin: -1em 0;
|
||||
}
|
||||
[slot="introduction"] a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
ha-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
ha-card .card-header {
|
||||
margin-bottom: -8px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
ha-alert,
|
||||
cloud-login {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -9,14 +9,12 @@ import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/input/ha-input";
|
||||
import type { HaInput } from "../../../../components/input/ha-input";
|
||||
import { setAssistPipelinePreferred } from "../../../../data/assist_pipeline";
|
||||
import { cloudLogin } from "../../../../data/cloud";
|
||||
import { loginHaCloud } from "../../../../data/onboarding";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
showPromptDialog,
|
||||
} from "../../../lovelace/custom-card-helpers";
|
||||
import { showCloudAlreadyConnectedDialog } from "../dialog-cloud-already-connected/show-dialog-cloud-already-connected";
|
||||
@@ -37,6 +35,8 @@ export class CloudLogin extends LitElement {
|
||||
|
||||
@property({ type: Boolean, attribute: "card-less" }) public cardLess = false;
|
||||
|
||||
@property() public lead?: string;
|
||||
|
||||
@query("#email", true) public emailField!: HaInput;
|
||||
|
||||
@query("#password", true) private _passwordField!: HaInput;
|
||||
@@ -50,21 +50,13 @@ export class CloudLogin extends LitElement {
|
||||
return this._renderLoginForm();
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.localize(
|
||||
`ui.panel.${this.translationKeyPanel}.login.sign_in`
|
||||
)}
|
||||
>
|
||||
${this._renderLoginForm()}
|
||||
</ha-card>
|
||||
`;
|
||||
return html`<ha-card outlined>${this._renderLoginForm()}</ha-card>`;
|
||||
}
|
||||
|
||||
private _renderLoginForm() {
|
||||
return html`
|
||||
<div class="card-content login-form">
|
||||
${this.lead ? html`<p class="lead">${this.lead}</p>` : nothing}
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
@@ -219,28 +211,12 @@ export class CloudLogin extends LitElement {
|
||||
|
||||
try {
|
||||
if (this.hass) {
|
||||
const result = await cloudLogin({
|
||||
await cloudLogin({
|
||||
hass: this.hass,
|
||||
email,
|
||||
...(code ? { code } : { password }),
|
||||
check_connection: checkConnection,
|
||||
});
|
||||
if (result.cloud_pipeline) {
|
||||
if (
|
||||
await showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.cloud.login.cloud_pipeline_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.cloud.login.cloud_pipeline_text"
|
||||
),
|
||||
confirmText: this.hass.localize("ui.common.yes"),
|
||||
dismissText: this.hass.localize("ui.common.no"),
|
||||
})
|
||||
) {
|
||||
setAssistPipelinePreferred(this.hass, result.cloud_pipeline);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// for onboarding
|
||||
await loginHaCloud({
|
||||
@@ -317,9 +293,6 @@ export class CloudLogin extends LitElement {
|
||||
ha-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
ha-card .card-header {
|
||||
margin-bottom: -8px;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -329,6 +302,11 @@ export class CloudLogin extends LitElement {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.lead {
|
||||
margin: 0 0 var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,212 +1,377 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { mdiEmailCheckOutline } from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../../common/navigate";
|
||||
import "../../../../components/buttons/ha-progress-button";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-spinner";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/input/ha-input";
|
||||
import type { HaInput } from "../../../../components/input/ha-input";
|
||||
import { cloudRegister, cloudResendVerification } from "../../../../data/cloud";
|
||||
import type { CloudAutoLogin, CloudStatus } from "../../../../data/cloud";
|
||||
import {
|
||||
attemptCloudAutoLoginNow,
|
||||
cloudRegisterAutoLogin,
|
||||
resendCloudAutoLoginConfirm,
|
||||
subscribeCloudEvents,
|
||||
} from "../../../../data/cloud";
|
||||
import "../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import "../../ha-config-section";
|
||||
import { showToast } from "../../../../util/toast";
|
||||
import "../cloud-signed-out-menu";
|
||||
import { cloudSignedOutStyle } from "../cloud-signed-out-style";
|
||||
import { cloudSubpageStyle } from "../account/cloud-subpage-style";
|
||||
|
||||
const BACK_PATH = "/config/cloud/start";
|
||||
|
||||
@customElement("cloud-register")
|
||||
export class CloudRegister extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property() public email?: string;
|
||||
|
||||
@property({ attribute: false }) public cloudStatus?: CloudStatus;
|
||||
|
||||
@state() private _view: "form" | "confirm" = "form";
|
||||
|
||||
@state() private _requestInProgress = false;
|
||||
|
||||
@state() private _password = "";
|
||||
@state() private _resendInProgress = false;
|
||||
|
||||
@state() private _autoLoginError?: string;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@query("#email", true) private _emailField!: HaInput;
|
||||
@state() private _success?: string;
|
||||
|
||||
@query("#password", true) private _passwordField!: HaInput;
|
||||
@state() private _registeredEmail = "";
|
||||
|
||||
private _unsubCloudEvents?: Promise<UnsubscribeFunc>;
|
||||
|
||||
@query("#email") private _emailField?: HaInput;
|
||||
|
||||
@query("#password") private _passwordField?: HaInput;
|
||||
|
||||
@query("#confirm-title") private _confirmTitle?: HTMLElement;
|
||||
|
||||
private get _autoLogin(): CloudAutoLogin | null {
|
||||
const status = this.cloudStatus;
|
||||
if (!status || status.logged_in) {
|
||||
return null;
|
||||
}
|
||||
return status.auto_login ?? null;
|
||||
}
|
||||
|
||||
private get _pendingEmail(): string {
|
||||
return this._registeredEmail || this._autoLogin?.email || "";
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this._autoLogin) {
|
||||
this._view = "confirm";
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._stopWatchingAutoLogin();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
|
||||
if (this._view !== "confirm") {
|
||||
return;
|
||||
}
|
||||
|
||||
const failedKey = this._autoLogin?.failed;
|
||||
if (failedKey && !this._autoLoginError) {
|
||||
this._failAutoLogin(failedKey);
|
||||
return;
|
||||
}
|
||||
|
||||
this._watchAutoLogin();
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const confirming = this._view === "confirm";
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
.header=${this.hass.localize("ui.panel.config.cloud.register.title")}
|
||||
back-path=${BACK_PATH}
|
||||
.backCallback=${confirming ? this._handleConfirmBack : undefined}
|
||||
.header=${this.hass.localize("ui.panel.config.cloud.register.headline")}
|
||||
>
|
||||
<cloud-signed-out-menu
|
||||
slot="toolbar-icon"
|
||||
.hass=${this.hass}
|
||||
></cloud-signed-out-menu>
|
||||
<div class="content">
|
||||
<ha-config-section .isWide=${this.isWide}>
|
||||
<span slot="header"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.headline"
|
||||
)}</span
|
||||
>
|
||||
<div slot="introduction">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.information"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.information2"
|
||||
)}
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.feature_remote_control"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.feature_google_home"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.feature_amazon_alexa"
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.feature_webhook_apps"
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.information3"
|
||||
)}
|
||||
<a
|
||||
href="https://www.nabucasa.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>Nabu Casa, Inc</a
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.information3a"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.information4"
|
||||
)}
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.nabucasa.com/tos/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.link_terms_conditions"
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.nabucasa.com/privacy_policy/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.link_privacy_policy"
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ha-card
|
||||
outlined
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.create_account"
|
||||
)}
|
||||
><div class="card-content register-form">
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="error"
|
||||
>${this._error}</ha-alert
|
||||
>`
|
||||
: ""
|
||||
}
|
||||
<ha-input
|
||||
autofocus
|
||||
id="email"
|
||||
name="email"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.email_address"
|
||||
)}
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
.value=${this.email ?? ""}
|
||||
@keydown=${this._keyDown}
|
||||
.validationMessage=${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.email_error_msg"
|
||||
)}
|
||||
></ha-input>
|
||||
<ha-input
|
||||
id="password"
|
||||
type="password"
|
||||
password-toggle
|
||||
name="password"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.password"
|
||||
)}
|
||||
.value=${this._password}
|
||||
autocomplete="new-password"
|
||||
minlength="8"
|
||||
required
|
||||
@keydown=${this._keyDown}
|
||||
.validationMessage=${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.password_error_msg"
|
||||
)}
|
||||
></ha-input>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="link"
|
||||
.disabled=${this._requestInProgress}
|
||||
@click=${this._handleResendVerifyEmail}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.resend_confirm_email"
|
||||
)}
|
||||
</button>
|
||||
<ha-progress-button
|
||||
@click=${this._handleRegister}
|
||||
.progress=${this._requestInProgress}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.start_trial"
|
||||
)}</ha-progress-button
|
||||
>
|
||||
</div>
|
||||
</ha-card>
|
||||
</ha-config-section>
|
||||
${confirming ? this._renderConfirm() : this._renderForm()}
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderForm(): TemplateResult {
|
||||
return html`
|
||||
<ha-card outlined>
|
||||
<div class="card-header-block">
|
||||
<h2>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.create_account"
|
||||
)}
|
||||
</h2>
|
||||
<p>
|
||||
${this.hass.localize("ui.panel.config.cloud.register.information")}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-content register-form">
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
<ha-input
|
||||
autofocus
|
||||
id="email"
|
||||
name="email"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.email_address"
|
||||
)}
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
.value=${this.email ?? ""}
|
||||
.disabled=${this._requestInProgress}
|
||||
@keydown=${this._keyDown}
|
||||
.validationMessage=${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.email_error_msg"
|
||||
)}
|
||||
></ha-input>
|
||||
<ha-input
|
||||
id="password"
|
||||
type="password"
|
||||
password-toggle
|
||||
name="password"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.password"
|
||||
)}
|
||||
autocomplete="new-password"
|
||||
minlength="8"
|
||||
required
|
||||
.disabled=${this._requestInProgress}
|
||||
@keydown=${this._keyDown}
|
||||
.validationMessage=${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.password_error_msg"
|
||||
)}
|
||||
></ha-input>
|
||||
<p class="terms">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.terms_notice",
|
||||
{
|
||||
terms: html`<a
|
||||
href="https://www.nabucasa.com/tos/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.link_terms_conditions"
|
||||
)}</a
|
||||
>`,
|
||||
privacy_policy: html`<a
|
||||
href="https://www.nabucasa.com/privacy_policy/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.link_privacy_policy"
|
||||
)}</a
|
||||
>`,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-actions split">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
.disabled=${this._requestInProgress}
|
||||
@click=${this._handleSignInInstead}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.sign_in_instead"
|
||||
)}
|
||||
</ha-button>
|
||||
<ha-progress-button
|
||||
@click=${this._handleRegister}
|
||||
.progress=${this._requestInProgress}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.start_trial"
|
||||
)}</ha-progress-button
|
||||
>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderConfirm(): TemplateResult {
|
||||
return html`
|
||||
<ha-card outlined>
|
||||
<div class="card-content confirm">
|
||||
${
|
||||
this._autoLoginError
|
||||
? html`<ha-alert alert-type="error">
|
||||
${this._autoLoginError}
|
||||
</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._success
|
||||
? html`<ha-alert alert-type="success">${this._success}</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
<div class="confirm-icon">
|
||||
<ha-svg-icon .path=${mdiEmailCheckOutline}></ha-svg-icon>
|
||||
</div>
|
||||
<h2 id="confirm-title" tabindex="-1">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.check_your_email"
|
||||
)}
|
||||
</h2>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.confirm_email_body",
|
||||
{
|
||||
email: html`<span class="email">${this._pendingEmail}</span>`,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
${
|
||||
this._autoLoginError
|
||||
? nothing
|
||||
: html`
|
||||
<div class="waiting">
|
||||
<ha-spinner size="tiny"></ha-spinner>
|
||||
<span>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.waiting_for_confirmation"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
<div class="card-actions split confirm-actions">
|
||||
<ha-button appearance="plain" @click=${this._handleCancelPending}>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
${
|
||||
this._autoLoginError
|
||||
? html`<ha-button
|
||||
appearance="accent"
|
||||
@click=${this._handleSignInInstead}
|
||||
>
|
||||
${this.hass.localize("ui.panel.config.cloud.login.sign_in")}
|
||||
</ha-button>`
|
||||
: html`<ha-button
|
||||
appearance="accent"
|
||||
@click=${this._handleAttemptNow}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.clicked_confirm"
|
||||
)}
|
||||
</ha-button>`
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
${
|
||||
this._autoLoginError
|
||||
? nothing
|
||||
: html`
|
||||
<p class="footnote">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.nothing_arrived",
|
||||
{
|
||||
resend_link: html`<button
|
||||
class="link"
|
||||
.disabled=${this._resendInProgress}
|
||||
@click=${this._handleResendVerifyEmail}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.resend_link"
|
||||
)}
|
||||
</button>`,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
`
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private _keyDown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter") {
|
||||
this._handleRegister();
|
||||
}
|
||||
}
|
||||
|
||||
private _handleSignInInstead() {
|
||||
const confirming = this._view === "confirm";
|
||||
fireEvent(this, "cloud-email-changed", {
|
||||
// The field wins on the form: it holds whatever was typed after a cancel.
|
||||
value: this._emailField?.value || this._pendingEmail || this.email || "",
|
||||
});
|
||||
this._stopWatchingAutoLogin();
|
||||
if (confirming) {
|
||||
fireEvent(this, "cloud-cancel-auto-login");
|
||||
}
|
||||
// Replaces only from the waiting view, whose registration is being
|
||||
// cancelled: browser-back must not return to it. The form is a page worth
|
||||
// going back to.
|
||||
navigate("/config/cloud/login", { replace: confirming });
|
||||
}
|
||||
|
||||
private _handleConfirmBack = () => {
|
||||
this._stopWatchingAutoLogin();
|
||||
fireEvent(this, "cloud-cancel-auto-login");
|
||||
navigate(BACK_PATH, { replace: true });
|
||||
};
|
||||
|
||||
private _handleCancelPending() {
|
||||
this._resetToForm();
|
||||
fireEvent(this, "cloud-cancel-auto-login");
|
||||
}
|
||||
|
||||
private _resetToForm() {
|
||||
this._stopWatchingAutoLogin();
|
||||
this._view = "form";
|
||||
this._autoLoginError = undefined;
|
||||
this._registeredEmail = "";
|
||||
this._error = undefined;
|
||||
this._success = undefined;
|
||||
}
|
||||
|
||||
private async _handleRegister() {
|
||||
const emailField = this._emailField;
|
||||
const passwordField = this._passwordField;
|
||||
|
||||
if (!emailField || !passwordField) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!emailField.reportValidity()) {
|
||||
passwordField.reportValidity();
|
||||
emailField.focus();
|
||||
@@ -222,92 +387,246 @@ export class CloudRegister extends LitElement {
|
||||
const password = passwordField.value || "";
|
||||
|
||||
this._requestInProgress = true;
|
||||
this._error = undefined;
|
||||
|
||||
try {
|
||||
await cloudRegister(this.hass, email, password);
|
||||
this._verificationEmailSent(email, "account_created");
|
||||
await cloudRegisterAutoLogin(this.hass, email, password);
|
||||
} catch (err: any) {
|
||||
this._password = "";
|
||||
this._requestInProgress = false;
|
||||
passwordField.value = "";
|
||||
this._error =
|
||||
err && err.body && err.body.message
|
||||
? err.body.message
|
||||
: "Unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
private async _handleResendVerifyEmail() {
|
||||
const emailField = this._emailField;
|
||||
|
||||
if (!emailField.reportValidity()) {
|
||||
emailField.focus();
|
||||
err?.body?.message || this.hass.localize("ui.common.unknown_error");
|
||||
return;
|
||||
}
|
||||
|
||||
const email = emailField.value || "";
|
||||
|
||||
this._requestInProgress = true;
|
||||
|
||||
const doResend = async (username: string) => {
|
||||
try {
|
||||
await cloudResendVerification(this.hass, username);
|
||||
this._verificationEmailSent(username, "verification_email_sent");
|
||||
} catch (err: any) {
|
||||
const errCode = err && err.body && err.body.code;
|
||||
if (errCode === "usernotfound" && username !== username.toLowerCase()) {
|
||||
await doResend(username.toLowerCase());
|
||||
} else {
|
||||
this._requestInProgress = false;
|
||||
this._error =
|
||||
err && err.body && err.body.message
|
||||
? err.body.message
|
||||
: "Unknown error";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await doResend(email);
|
||||
this._registeredEmail = email;
|
||||
this._requestInProgress = false;
|
||||
this._view = "confirm";
|
||||
this._autoLoginError = undefined;
|
||||
fireEvent(this, "cloud-email-changed", { value: email });
|
||||
fireEvent(this, "cloud-auto-login-started");
|
||||
fireEvent(this, "ha-refresh-cloud-status");
|
||||
this._watchAutoLogin();
|
||||
await this.updateComplete;
|
||||
this._confirmTitle?.focus();
|
||||
}
|
||||
|
||||
private _verificationEmailSent(
|
||||
email: string,
|
||||
messageKey: "account_created" | "verification_email_sent"
|
||||
) {
|
||||
this._requestInProgress = false;
|
||||
this._password = "";
|
||||
fireEvent(this, "cloud-email-changed", { value: email });
|
||||
fireEvent(this, "cloud-done", {
|
||||
flashMessage: this.hass.localize(
|
||||
`ui.panel.config.cloud.register.${messageKey}`
|
||||
),
|
||||
private _watchAutoLogin() {
|
||||
if (this._unsubCloudEvents || this._autoLoginError) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = subscribeCloudEvents(this.hass, (event) => {
|
||||
if (event.type === "login") {
|
||||
fireEvent(this, "ha-refresh-cloud-status");
|
||||
} else if (event.type === "auto_login_failed") {
|
||||
this._failAutoLogin(event.translation_key);
|
||||
} else if (event.type === "auto_login_cancelled") {
|
||||
this._resetToForm();
|
||||
fireEvent(this, "ha-refresh-cloud-status");
|
||||
}
|
||||
});
|
||||
this._unsubCloudEvents = subscription;
|
||||
|
||||
subscription.catch(() => {
|
||||
if (this._unsubCloudEvents === subscription) {
|
||||
this._unsubCloudEvents = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _localizeBackendMessage(
|
||||
key: string,
|
||||
domain = "cloud"
|
||||
): Promise<string | undefined> {
|
||||
const localize = await this.hass.loadBackendTranslation(
|
||||
"exceptions",
|
||||
domain
|
||||
);
|
||||
return (
|
||||
localize(`component.${domain}.exceptions.${key}.message`) || undefined
|
||||
);
|
||||
}
|
||||
|
||||
private async _websocketErrorMessage(err: any): Promise<string> {
|
||||
if (err?.translation_key) {
|
||||
const message = await this._localizeBackendMessage(
|
||||
err.translation_key,
|
||||
err.translation_domain
|
||||
);
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return err?.message || this.hass.localize("ui.common.unknown_error");
|
||||
}
|
||||
|
||||
private async _failAutoLogin(translationKey?: string | null) {
|
||||
this._stopWatchingAutoLogin();
|
||||
const reason = translationKey
|
||||
? await this._localizeBackendMessage(translationKey)
|
||||
: undefined;
|
||||
this._error = undefined;
|
||||
this._success = undefined;
|
||||
this._autoLoginError =
|
||||
reason ||
|
||||
this.hass.localize("ui.panel.config.cloud.register.auto_login_failed");
|
||||
fireEvent(this, "ha-refresh-cloud-status");
|
||||
}
|
||||
|
||||
private _stopWatchingAutoLogin() {
|
||||
this._unsubCloudEvents?.then((unsub) => unsub()).catch(() => undefined);
|
||||
this._unsubCloudEvents = undefined;
|
||||
}
|
||||
|
||||
private async _handleAttemptNow() {
|
||||
this._watchAutoLogin();
|
||||
|
||||
try {
|
||||
await attemptCloudAutoLoginNow(this.hass);
|
||||
} catch (err: any) {
|
||||
showToast(this, { message: await this._websocketErrorMessage(err) });
|
||||
return;
|
||||
}
|
||||
|
||||
showToast(this, {
|
||||
message: {
|
||||
translationKey: "ui.panel.config.cloud.register.checking_confirmation",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async _handleResendVerifyEmail() {
|
||||
if (this._resendInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._error = undefined;
|
||||
this._success = undefined;
|
||||
this._resendInProgress = true;
|
||||
|
||||
try {
|
||||
await resendCloudAutoLoginConfirm(this.hass);
|
||||
this._success = this.hass.localize(
|
||||
"ui.panel.config.cloud.register.verification_email_sent"
|
||||
);
|
||||
} catch (err: any) {
|
||||
this._error = await this._websocketErrorMessage(err);
|
||||
} finally {
|
||||
this._resendInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
haStyle,
|
||||
cloudSubpageStyle,
|
||||
cloudSignedOutStyle,
|
||||
css`
|
||||
[slot="introduction"] {
|
||||
margin: -1em 0;
|
||||
.content {
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
[slot="introduction"] a {
|
||||
color: var(--primary-color);
|
||||
|
||||
.card-header-block {
|
||||
padding: var(--ha-space-4) var(--ha-space-4) 0;
|
||||
}
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
.card-header-block p {
|
||||
margin: var(--ha-space-2) 0 0;
|
||||
color: var(--secondary-text-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
}
|
||||
|
||||
.register-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.card-actions {
|
||||
.terms {
|
||||
margin: var(--ha-space-2) 0 0;
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
}
|
||||
.terms a,
|
||||
.footnote a,
|
||||
.footnote button.link {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.card-actions.split {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.card-actions.confirm-actions {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: var(--ha-space-3);
|
||||
padding: var(--ha-space-8) var(--ha-space-4) var(--ha-space-5);
|
||||
}
|
||||
.confirm ha-alert {
|
||||
width: 100%;
|
||||
text-align: initial;
|
||||
}
|
||||
.confirm-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: var(--ha-border-radius-pill);
|
||||
background: color-mix(in srgb, var(--info-color) 18%, transparent);
|
||||
color: var(--info-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
--mdc-icon-size: 28px;
|
||||
}
|
||||
/* Focused programmatically to announce the view; no visible ring. */
|
||||
#confirm-title:focus {
|
||||
outline: none;
|
||||
}
|
||||
.confirm p {
|
||||
margin: 0;
|
||||
max-width: 420px;
|
||||
color: var(--secondary-text-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
}
|
||||
.confirm .email {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.waiting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.footnote {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
text-align: center;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* Not column-reverse: that paints the buttons against DOM order, so
|
||||
keyboard and screen-reader users reach them back to front. */
|
||||
@media (max-width: 500px) {
|
||||
.card-actions.split {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
@@ -319,6 +638,7 @@ declare global {
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"cloud-done": { flashMessage: string };
|
||||
"cloud-cancel-auto-login": undefined;
|
||||
"cloud-auto-login-started": undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import {
|
||||
mdiBackupRestore,
|
||||
mdiCellphone,
|
||||
mdiEarth,
|
||||
mdiHandHeart,
|
||||
mdiMicrophone,
|
||||
mdiMicrophoneMessage,
|
||||
} from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { navigate } from "../../../../common/navigate";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import "../cloud-signed-out-menu";
|
||||
import { cloudSignedOutStyle } from "../cloud-signed-out-style";
|
||||
import { cloudSubpageStyle } from "../account/cloud-subpage-style";
|
||||
|
||||
const FEATURES = [
|
||||
{ icon: mdiHandHeart, color: "--red-color", key: "feature_support" },
|
||||
{ icon: mdiEarth, color: "--blue-color", key: "feature_remote" },
|
||||
{ icon: mdiBackupRestore, color: "--green-color", key: "feature_backup" },
|
||||
{
|
||||
icon: mdiMicrophoneMessage,
|
||||
color: "--cyan-color",
|
||||
key: "feature_voice_control",
|
||||
},
|
||||
{
|
||||
icon: mdiMicrophone,
|
||||
color: "--purple-color",
|
||||
key: "feature_voice_quality",
|
||||
},
|
||||
{ icon: mdiCellphone, color: "--primary-color", key: "feature_companion" },
|
||||
] as const;
|
||||
|
||||
@customElement("cloud-start")
|
||||
export class CloudStart extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.login.landing_title"
|
||||
)}
|
||||
>
|
||||
<cloud-signed-out-menu
|
||||
slot="toolbar-icon"
|
||||
.hass=${this.hass}
|
||||
></cloud-signed-out-menu>
|
||||
<div class="content">
|
||||
<div class="landing">
|
||||
<div class="pitch">
|
||||
<h2>
|
||||
${this.hass.localize("ui.panel.config.cloud.login.hero_title")}
|
||||
</h2>
|
||||
<p class="lead">
|
||||
${this.hass.localize("ui.panel.config.cloud.login.hero_lead")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ha-card outlined class="features">
|
||||
<div class="card-content feature-list">
|
||||
${FEATURES.map(
|
||||
(feature) => html`
|
||||
<div class="feature">
|
||||
<div
|
||||
class="icon-tile"
|
||||
style=${styleMap({
|
||||
"--feature-tint": `var(${feature.color})`,
|
||||
})}
|
||||
>
|
||||
<ha-svg-icon .path=${feature.icon}></ha-svg-icon>
|
||||
</div>
|
||||
<div class="feature-text">
|
||||
<div class="feature-title">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.cloud.login.${feature.key}_title`
|
||||
)}
|
||||
</div>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.cloud.login.${feature.key}_body`
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</ha-card>
|
||||
|
||||
<div class="actions">
|
||||
<div class="action-buttons">
|
||||
<ha-button
|
||||
size="l"
|
||||
appearance="accent"
|
||||
@click=${this._handleRegister}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.register.headline"
|
||||
)}
|
||||
</ha-button>
|
||||
<ha-button appearance="plain" @click=${this._handleSignIn}>
|
||||
${this.hass.localize("ui.panel.config.cloud.login.sign_in")}
|
||||
</ha-button>
|
||||
</div>
|
||||
<p class="trial-note">
|
||||
${this.hass.localize("ui.panel.config.cloud.login.trial_note")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="footnote">
|
||||
${this.hass.localize("ui.panel.config.cloud.login.partner_note", {
|
||||
nabu_casa_link: html`<a
|
||||
href="https://www.nabucasa.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>Nabu Casa, Inc</a
|
||||
>`,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleSignIn() {
|
||||
navigate("/config/cloud/login");
|
||||
}
|
||||
|
||||
private _handleRegister() {
|
||||
navigate("/config/cloud/register");
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
haStyle,
|
||||
cloudSubpageStyle,
|
||||
cloudSignedOutStyle,
|
||||
css`
|
||||
.content {
|
||||
min-height: 100%;
|
||||
gap: var(--ha-space-4);
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.landing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-5);
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.pitch .lead {
|
||||
margin: var(--ha-space-2) 0 0;
|
||||
line-height: var(--ha-line-height-normal);
|
||||
color: var(--secondary-text-color);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-5);
|
||||
padding: var(--ha-space-5) var(--ha-space-4);
|
||||
}
|
||||
.feature {
|
||||
display: flex;
|
||||
gap: var(--ha-space-3);
|
||||
align-items: flex-start;
|
||||
}
|
||||
.feature-title {
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
.feature-text p {
|
||||
margin: var(--ha-space-1) 0 0;
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.icon-tile {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--ha-border-radius-pill);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--feature-tint) 15%, transparent);
|
||||
color: var(--feature-tint);
|
||||
}
|
||||
|
||||
/* Keeps the primary action reachable while the feature list scrolls. */
|
||||
.actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
padding: var(--ha-space-3) 0
|
||||
calc(var(--safe-area-inset-bottom) + var(--ha-space-3));
|
||||
background: var(--primary-background-color);
|
||||
box-shadow: 0 -1px 0 var(--divider-color);
|
||||
}
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.action-buttons ha-button {
|
||||
flex: 1;
|
||||
/* Matches the primary action's size="l" height without also giving
|
||||
the secondary its larger type. */
|
||||
--ha-button-height: 48px;
|
||||
}
|
||||
.trial-note {
|
||||
margin: 0;
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footnote {
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
/* Keep the footer at the bottom on short pages. */
|
||||
margin: auto auto 0;
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
text-align: center;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.footnote a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
@container (min-width: 700px) {
|
||||
.landing {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
|
||||
grid-template-rows: auto auto 1fr;
|
||||
column-gap: var(--ha-space-7);
|
||||
row-gap: var(--ha-space-6);
|
||||
align-items: start;
|
||||
max-width: 960px;
|
||||
}
|
||||
.pitch {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
.actions {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
position: static;
|
||||
padding: 0;
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.features {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / 4;
|
||||
}
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
max-width: 340px;
|
||||
}
|
||||
.action-buttons ha-button {
|
||||
flex: initial;
|
||||
width: 100%;
|
||||
}
|
||||
.trial-note {
|
||||
text-align: start;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"cloud-start": CloudStart;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { listenMediaQuery } from "../../common/dom/media_query";
|
||||
import type { CloudStatus } from "../../data/cloud";
|
||||
import { fetchCloudStatus } from "../../data/cloud";
|
||||
import { fetchCloudStatus, subscribeCloudEvents } from "../../data/cloud";
|
||||
import {
|
||||
entityRegistryByEntityId,
|
||||
entityRegistryById,
|
||||
@@ -256,6 +257,10 @@ class HaPanelConfig extends HassRouterPage {
|
||||
|
||||
private _listeners: (() => void)[] = [];
|
||||
|
||||
private _unsubCloudEvents?: Promise<UnsubscribeFunc>;
|
||||
|
||||
private _cloudStatusRequestId = 0;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._listeners.push(
|
||||
@@ -268,6 +273,10 @@ class HaPanelConfig extends HassRouterPage {
|
||||
this._wideSidebar = matches;
|
||||
})
|
||||
);
|
||||
if (this.hass && isComponentLoaded(this.hass.config, "cloud")) {
|
||||
this._subscribeCloudEvents();
|
||||
this._updateCloudStatus();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
@@ -275,19 +284,42 @@ class HaPanelConfig extends HassRouterPage {
|
||||
while (this._listeners.length) {
|
||||
this._listeners.pop()!();
|
||||
}
|
||||
this._unsubCloudEvents?.then((unsub) => unsub()).catch(() => undefined);
|
||||
this._unsubCloudEvents = undefined;
|
||||
entityRegistryByEntityId.clear();
|
||||
entityRegistryById.clear();
|
||||
}
|
||||
|
||||
private _subscribeCloudEvents() {
|
||||
if (
|
||||
this._unsubCloudEvents ||
|
||||
!this.hass ||
|
||||
!isComponentLoaded(this.hass.config, "cloud")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = subscribeCloudEvents(this.hass, () => {
|
||||
this._updateCloudStatus();
|
||||
});
|
||||
this._unsubCloudEvents = subscription;
|
||||
|
||||
subscription.catch(() => {
|
||||
if (this._unsubCloudEvents === subscription) {
|
||||
this._unsubCloudEvents = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this.hass.loadBackendTranslation("title");
|
||||
this.hass.loadBackendTranslation("services");
|
||||
if (isComponentLoaded(this.hass.config, "cloud")) {
|
||||
this._updateCloudStatus();
|
||||
this.addEventListener("connection-status", (ev) => {
|
||||
if (ev.detail === "connected") {
|
||||
this._updateCloudStatus();
|
||||
this._subscribeCloudEvents();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -321,7 +353,13 @@ class HaPanelConfig extends HassRouterPage {
|
||||
}
|
||||
|
||||
private async _updateCloudStatus() {
|
||||
this._cloudStatus = await fetchCloudStatus(this.hass);
|
||||
const requestId = ++this._cloudStatusRequestId;
|
||||
const status = await fetchCloudStatus(this.hass);
|
||||
|
||||
if (requestId !== this._cloudStatusRequestId) {
|
||||
return;
|
||||
}
|
||||
this._cloudStatus = status;
|
||||
|
||||
if (
|
||||
// Relayer connecting
|
||||
|
||||
@@ -101,12 +101,16 @@ export const renderConfigEntryError = (
|
||||
): TemplateResult => {
|
||||
if (entry.reason) {
|
||||
if (entry.error_reason_translation_key) {
|
||||
// The error may be translated by another integration, e.g. one raised by
|
||||
// a shared helper and owned by the homeassistant integration.
|
||||
const translationDomain =
|
||||
entry.error_reason_translation_domain || entry.domain;
|
||||
const lokalisePromExc = hass
|
||||
.loadBackendTranslation("exceptions", entry.domain)
|
||||
.loadBackendTranslation("exceptions", translationDomain)
|
||||
.then(
|
||||
(localize) =>
|
||||
localize(
|
||||
`component.${entry.domain}.exceptions.${entry.error_reason_translation_key}.message`,
|
||||
`component.${translationDomain}.exceptions.${entry.error_reason_translation_key}.message`,
|
||||
entry.error_reason_translation_placeholders ?? undefined
|
||||
) || entry.reason
|
||||
);
|
||||
|
||||
@@ -267,6 +267,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
pref_disable_polling: false,
|
||||
disabled_by: null,
|
||||
reason: null,
|
||||
error_reason_translation_domain: null,
|
||||
error_reason_translation_key: null,
|
||||
error_reason_translation_placeholders: null,
|
||||
})
|
||||
|
||||
+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}
|
||||
>
|
||||
|
||||
@@ -395,9 +395,11 @@ export class VoiceAssistantsExpose extends LitElement {
|
||||
aliases: entry?.aliases || [],
|
||||
};
|
||||
}
|
||||
result[entityId].assistants_sortable_key = getAssistantsSortableKey(
|
||||
result[entityId].assistants
|
||||
);
|
||||
if (result[entityId]) {
|
||||
result[entityId].assistants_sortable_key = getAssistantsSortableKey(
|
||||
result[entityId].assistants
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
return Object.values(result);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { HassDialog } from "../../../dialogs/make-dialog-manager";
|
||||
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
|
||||
import "../components/home-favorites-editor";
|
||||
import "../../../components/entity/ha-favorites-editor";
|
||||
import "../components/home-shortcuts-editor";
|
||||
import type { EditHomeDialogParams } from "./show-dialog-edit-home";
|
||||
|
||||
@@ -123,14 +123,16 @@ export class DialogEditHome
|
||||
@value-changed=${this._welcomeChanged}
|
||||
></ha-form>
|
||||
|
||||
<home-favorites-editor
|
||||
.hass=${this.hass}
|
||||
<ha-favorites-editor
|
||||
.favorites=${this._state.favorite_entities}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.strategy.home.favorite_entities"
|
||||
)}
|
||||
.addButtonLabel=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.strategy.home.add_favorite_entity"
|
||||
)}
|
||||
@value-changed=${this._favoriteEntitiesChanged}
|
||||
></home-favorites-editor>
|
||||
></ha-favorites-editor>
|
||||
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
@@ -309,7 +311,7 @@ export class DialogEditHome
|
||||
display: block;
|
||||
}
|
||||
|
||||
home-favorites-editor {
|
||||
ha-favorites-editor {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-2);
|
||||
margin-bottom: var(--ha-space-4);
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import type { LovelacePanelConfig } from "../../../data/lovelace";
|
||||
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
||||
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
|
||||
import type { LovelaceConfig } from "../../../data/lovelace/config/types";
|
||||
import { fetchConfig, saveConfig } from "../../../data/lovelace/config/types";
|
||||
import type {
|
||||
LovelaceConfig,
|
||||
LovelaceRawConfig,
|
||||
} from "../../../data/lovelace/config/types";
|
||||
import {
|
||||
fetchConfig,
|
||||
isStrategyDashboard,
|
||||
saveConfig,
|
||||
} from "../../../data/lovelace/config/types";
|
||||
import { fetchDashboards } from "../../../data/lovelace/dashboard";
|
||||
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
@@ -37,7 +44,15 @@ export const addEntitiesToLovelaceView = async (
|
||||
return;
|
||||
}
|
||||
|
||||
let lovelaceConfig;
|
||||
// A strategy dashboard has no views of its own until the user takes control.
|
||||
const hasViewList = (
|
||||
config: LovelaceRawConfig | undefined
|
||||
): config is LovelaceConfig => !!config && !isStrategyDashboard(config);
|
||||
|
||||
const hasViews = (config: LovelaceRawConfig | undefined) =>
|
||||
hasViewList(config) && !!config.views?.length;
|
||||
|
||||
let lovelaceConfig: LovelaceRawConfig | undefined;
|
||||
let urlPath: string | null = null;
|
||||
if (mainLovelaceMode === "storage") {
|
||||
try {
|
||||
@@ -47,25 +62,36 @@ export const addEntitiesToLovelaceView = async (
|
||||
}
|
||||
}
|
||||
|
||||
if (!lovelaceConfig && storageDashs.length) {
|
||||
// find first dashoard not in generated mode
|
||||
if (!hasViews(lovelaceConfig)) {
|
||||
// Prefer a dashboard that has views to add the card to, but keep the first
|
||||
// usable one as a fallback so the user still gets the dashboard picker.
|
||||
for (const storageDash of storageDashs) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
lovelaceConfig = await fetchConfig(
|
||||
const dashConfig = await fetchConfig(
|
||||
hass.connection,
|
||||
storageDash.url_path,
|
||||
false
|
||||
);
|
||||
urlPath = storageDash.url_path;
|
||||
break;
|
||||
if (!hasViewList(dashConfig)) {
|
||||
continue;
|
||||
}
|
||||
if (!hasViewList(lovelaceConfig)) {
|
||||
lovelaceConfig = dashConfig;
|
||||
urlPath = storageDash.url_path;
|
||||
}
|
||||
if (hasViews(dashConfig)) {
|
||||
lovelaceConfig = dashConfig;
|
||||
urlPath = storageDash.url_path;
|
||||
break;
|
||||
}
|
||||
} catch (_err: any) {
|
||||
// dashboard is in generated mode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!lovelaceConfig) {
|
||||
if (!hasViewList(lovelaceConfig)) {
|
||||
if (dashboards.length > storageDashs.length) {
|
||||
// all storage dashboards are generated, but we have YAML dashboards just show the YAML config
|
||||
showSuggestCardDialog(element, {
|
||||
@@ -94,7 +120,7 @@ export const addEntitiesToLovelaceView = async (
|
||||
showSuggestCardDialog(element, {
|
||||
cardConfig,
|
||||
sectionConfig,
|
||||
lovelaceConfig: lovelaceConfig!,
|
||||
lovelaceConfig,
|
||||
saveConfig: async (newConfig: LovelaceConfig): Promise<void> => {
|
||||
try {
|
||||
await saveConfig(hass!, null, newConfig);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,13 +9,14 @@ import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-list";
|
||||
import "../../../../components/ha-radio-list-item";
|
||||
import "../../../../components/ha-select";
|
||||
import "../../../../components/ha-spinner";
|
||||
import "../../../../components/ha-dialog";
|
||||
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
|
||||
import { fetchConfig } from "../../../../data/lovelace/config/types";
|
||||
import { isStrategyView } from "../../../../data/lovelace/config/view";
|
||||
import type { LovelaceDashboard } from "../../../../data/lovelace/dashboard";
|
||||
import { fetchDashboards } from "../../../../data/lovelace/dashboard";
|
||||
import { getDefaultPanelUrlPath } from "../../../../data/panel";
|
||||
import { LOVELACE_PANEL } from "../../../../data/panel";
|
||||
import { haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { SelectViewDialogParams } from "./show-select-view-dialog";
|
||||
@@ -42,11 +43,15 @@ export class HuiDialogSelectView extends LitElement {
|
||||
|
||||
@state() private _selectedViewIdx = 0;
|
||||
|
||||
@state() private _loading = false;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
public showDialog(params: SelectViewDialogParams): void {
|
||||
this._config = params.lovelaceConfig;
|
||||
this._urlPath = params.urlPath;
|
||||
this._selectedViewIdx = 0;
|
||||
this._loading = false;
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
if (this._params.allowDashboardChange) {
|
||||
@@ -68,8 +73,6 @@ export class HuiDialogSelectView extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const defaultPanel = getDefaultPanelUrlPath(this.hass);
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
@@ -86,19 +89,19 @@ export class HuiDialogSelectView extends LitElement {
|
||||
"ui.panel.lovelace.editor.select_view.dashboard_label"
|
||||
)}
|
||||
.disabled=${!this._dashboards.length}
|
||||
.value=${this._urlPath || defaultPanel}
|
||||
.value=${this._urlPath ?? LOVELACE_PANEL}
|
||||
@selected=${this._dashboardChanged}
|
||||
autofocus
|
||||
.options=${this._dashboards
|
||||
.map((dashboard) => ({
|
||||
value: dashboard.url_path,
|
||||
label: `${dashboard.title}${dashboard.id === "lovelace" ? ` (${this.hass.localize("ui.common.default")})` : ""}`,
|
||||
label: `${dashboard.title}${dashboard.id === LOVELACE_PANEL ? ` (${this.hass.localize("ui.common.default")})` : ""}`,
|
||||
disabled: dashboard.mode !== "storage",
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
a.value === "lovelace"
|
||||
a.value === LOVELACE_PANEL
|
||||
? -1
|
||||
: b.value === "lovelace"
|
||||
: b.value === LOVELACE_PANEL
|
||||
? 1
|
||||
: a.label.localeCompare(b.label)
|
||||
)}
|
||||
@@ -106,54 +109,7 @@ export class HuiDialogSelectView extends LitElement {
|
||||
</ha-select>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
!this._config || (this._config.views || []).length < 1
|
||||
? html`<ha-alert alert-type="error"
|
||||
>${this.hass.localize(
|
||||
this._config
|
||||
? "ui.panel.lovelace.editor.select_view.no_views"
|
||||
: "ui.panel.lovelace.editor.select_view.no_config"
|
||||
)}</ha-alert
|
||||
>`
|
||||
: this._config.views.length > 1
|
||||
? html`
|
||||
<ha-list>
|
||||
${this._config.views.map((view, idx) => {
|
||||
const isStrategy = isStrategyView(view);
|
||||
|
||||
return html`
|
||||
<ha-radio-list-item
|
||||
.graphic=${
|
||||
this._config?.views.some(({ icon }) => icon)
|
||||
? "icon"
|
||||
: nothing
|
||||
}
|
||||
@click=${this._viewChanged}
|
||||
.value=${idx.toString()}
|
||||
.selected=${this._selectedViewIdx === idx}
|
||||
.disabled=${
|
||||
isStrategy && !this._params?.includeStrategyViews
|
||||
}
|
||||
?autofocus=${
|
||||
idx === 0 && !this._params!.allowDashboardChange
|
||||
}
|
||||
>
|
||||
<span>
|
||||
${view.title}${
|
||||
isStrategy
|
||||
? ` (${this.hass.localize("ui.panel.lovelace.editor.select_view.strategy_type")})`
|
||||
: nothing
|
||||
}
|
||||
</span>
|
||||
|
||||
<ha-icon .icon=${view.icon} slot="graphic"></ha-icon>
|
||||
</ha-radio-list-item>
|
||||
`;
|
||||
})}
|
||||
</ha-list>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${this._renderViews()}
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
@@ -164,7 +120,7 @@ export class HuiDialogSelectView extends LitElement {
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
.disabled=${!this._config || (this._config.views || []).length < 1}
|
||||
.disabled=${!this._selectableConfig}
|
||||
@click=${this._selectView}
|
||||
>
|
||||
${this._params.actionLabel || this.hass!.localize("ui.common.move")}
|
||||
@@ -174,30 +130,97 @@ export class HuiDialogSelectView extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
// While a config is loading the views on screen still belong to the
|
||||
// previously selected dashboard, so nothing may be picked from them.
|
||||
private get _selectableConfig(): LovelaceConfig | undefined {
|
||||
return !this._loading && this._config?.views?.length
|
||||
? this._config
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private _renderViews() {
|
||||
if (this._loading) {
|
||||
return html`<div class="loading">
|
||||
<ha-spinner size="medium"></ha-spinner>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (!this._selectableConfig) {
|
||||
return html`<ha-alert alert-type="error">
|
||||
${this.hass.localize(
|
||||
this._config
|
||||
? "ui.panel.lovelace.editor.select_view.no_views"
|
||||
: "ui.panel.lovelace.editor.select_view.no_config"
|
||||
)}
|
||||
</ha-alert>`;
|
||||
}
|
||||
|
||||
const views = this._selectableConfig.views;
|
||||
if (views.length < 2) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const hasIcon = views.some(({ icon }) => icon);
|
||||
|
||||
return html`
|
||||
<ha-list>
|
||||
${views.map((view, idx) => {
|
||||
const isStrategy = isStrategyView(view);
|
||||
|
||||
return html`
|
||||
<ha-radio-list-item
|
||||
.graphic=${hasIcon ? "icon" : nothing}
|
||||
@click=${this._viewChanged}
|
||||
.value=${idx.toString()}
|
||||
.selected=${this._selectedViewIdx === idx}
|
||||
.disabled=${isStrategy && !this._params?.includeStrategyViews}
|
||||
?autofocus=${idx === 0 && !this._params!.allowDashboardChange}
|
||||
>
|
||||
<span>
|
||||
${view.title}${
|
||||
isStrategy
|
||||
? ` (${this.hass.localize("ui.panel.lovelace.editor.select_view.strategy_type")})`
|
||||
: nothing
|
||||
}
|
||||
</span>
|
||||
|
||||
<ha-icon .icon=${view.icon} slot="graphic"></ha-icon>
|
||||
</ha-radio-list-item>
|
||||
`;
|
||||
})}
|
||||
</ha-list>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _getDashboards() {
|
||||
this._dashboards =
|
||||
this._params!.dashboards || (await fetchDashboards(this.hass));
|
||||
}
|
||||
|
||||
private async _dashboardChanged(ev: ValueChangedEvent<string>) {
|
||||
let urlPath: string | null = ev.detail.value;
|
||||
const urlPath = ev.detail.value === LOVELACE_PANEL ? null : ev.detail.value;
|
||||
if (urlPath === this._urlPath) {
|
||||
return;
|
||||
}
|
||||
if (urlPath === "lovelace") {
|
||||
urlPath = null;
|
||||
}
|
||||
this._urlPath = urlPath;
|
||||
this._selectedViewIdx = 0;
|
||||
this._loading = true;
|
||||
let config: LovelaceConfig | undefined;
|
||||
try {
|
||||
this._config = (await fetchConfig(
|
||||
config = (await fetchConfig(
|
||||
this.hass.connection,
|
||||
urlPath,
|
||||
false
|
||||
)) as LovelaceConfig;
|
||||
} catch (_err: any) {
|
||||
this._config = undefined;
|
||||
config = undefined;
|
||||
}
|
||||
// Responses can resolve out of order.
|
||||
if (urlPath !== this._urlPath) {
|
||||
return;
|
||||
}
|
||||
this._config = config;
|
||||
this._selectedViewIdx = 0;
|
||||
this._loading = false;
|
||||
}
|
||||
|
||||
private _viewChanged(e) {
|
||||
@@ -209,10 +232,14 @@ export class HuiDialogSelectView extends LitElement {
|
||||
}
|
||||
|
||||
private _selectView(): void {
|
||||
const config = this._selectableConfig;
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "view-selected", { view: this._selectedViewIdx });
|
||||
this._params!.viewSelectedCallback(
|
||||
this._urlPath!,
|
||||
this._config!,
|
||||
config,
|
||||
this._selectedViewIdx
|
||||
);
|
||||
this.closeDialog();
|
||||
@@ -225,6 +252,10 @@ export class HuiDialogSelectView extends LitElement {
|
||||
ha-select {
|
||||
width: 100%;
|
||||
}
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
mwc-radio-list-item {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { HomeAssistant } from "../../../../types";
|
||||
|
||||
import {
|
||||
isMediaSourceContentId,
|
||||
resolveMediaSource,
|
||||
resolveMediaSourceWithCache,
|
||||
} from "../../../../data/media_source";
|
||||
|
||||
export interface BackgroundConfigTarget {
|
||||
@@ -142,21 +142,35 @@ export class HuiViewBackgroundEditor extends LitElement {
|
||||
`${(background.opacity ?? 100) / 100}`
|
||||
);
|
||||
|
||||
const backgroundImage =
|
||||
typeof background.image === "object"
|
||||
? background.image.media_content_id
|
||||
: background.image;
|
||||
const backgroundImage = this._currentBackgroundImage();
|
||||
|
||||
if (backgroundImage && isMediaSourceContentId(backgroundImage)) {
|
||||
resolveMediaSource(this.hass, backgroundImage).then((result) => {
|
||||
this._resolvedImage = result.url;
|
||||
});
|
||||
resolveMediaSourceWithCache(this.hass, backgroundImage).then(
|
||||
(result) => {
|
||||
// Discard if the image changed while resolving
|
||||
if (this._currentBackgroundImage() === backgroundImage) {
|
||||
this._resolvedImage = result.url;
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (this._currentBackgroundImage() === backgroundImage) {
|
||||
this._resolvedImage = undefined;
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
this._resolvedImage = backgroundImage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _currentBackgroundImage(): string | undefined {
|
||||
const background = this._backgroundData(this.config);
|
||||
return typeof background.image === "object"
|
||||
? background.image.media_content_id
|
||||
: background.image;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
|
||||
@@ -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,7 @@ import type { HomeAssistant } from "../../../types";
|
||||
import type { LovelaceViewBackgroundConfig } from "../../../data/lovelace/config/view";
|
||||
import {
|
||||
isMediaSourceContentId,
|
||||
resolveMediaSource,
|
||||
resolveMediaSourceWithCache,
|
||||
} from "../../../data/media_source";
|
||||
|
||||
@customElement("hui-view-background")
|
||||
@@ -21,20 +21,37 @@ export class HUIViewBackground extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
private _fetchMedia() {
|
||||
const backgroundImage =
|
||||
typeof this.background === "string"
|
||||
? this.background
|
||||
: typeof this.background?.image === "object"
|
||||
? this.background.image.media_content_id
|
||||
: this.background?.image;
|
||||
private _getBackgroundImage(
|
||||
background?: string | LovelaceViewBackgroundConfig
|
||||
): string | undefined {
|
||||
if (typeof background === "string") {
|
||||
return background;
|
||||
}
|
||||
if (typeof background?.image === "object") {
|
||||
return background.image.media_content_id;
|
||||
}
|
||||
return background?.image;
|
||||
}
|
||||
|
||||
if (backgroundImage && isMediaSourceContentId(backgroundImage)) {
|
||||
resolveMediaSource(this.hass, backgroundImage).then((result) => {
|
||||
this.resolvedImage = result.url;
|
||||
});
|
||||
} else {
|
||||
private async _fetchMedia() {
|
||||
const backgroundImage = this._getBackgroundImage(this.background);
|
||||
|
||||
if (!backgroundImage || !isMediaSourceContentId(backgroundImage)) {
|
||||
this.resolvedImage = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
let resolvedUrl: string | undefined;
|
||||
try {
|
||||
resolvedUrl = (
|
||||
await resolveMediaSourceWithCache(this.hass, backgroundImage)
|
||||
).url;
|
||||
} catch {
|
||||
resolvedUrl = undefined;
|
||||
}
|
||||
// Discard if the background changed while resolving
|
||||
if (this._getBackgroundImage(this.background) === backgroundImage) {
|
||||
this.resolvedImage = resolvedUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,10 +90,7 @@ export class HUIViewBackground extends LitElement {
|
||||
background?: string | LovelaceViewBackgroundConfig
|
||||
) {
|
||||
if (typeof background === "object" && background.image) {
|
||||
const image =
|
||||
typeof background.image === "object"
|
||||
? background.image.media_content_id || ""
|
||||
: background.image;
|
||||
const image = this._getBackgroundImage(background) || "";
|
||||
if (isMediaSourceContentId(image) && !this.resolvedImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiDelete, mdiDragHorizontalVariant } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
|
||||
import {
|
||||
fireEvent,
|
||||
type HASSDomCurrentTargetEvent,
|
||||
type HASSDomEvent,
|
||||
} from "../../../common/dom/fire_event";
|
||||
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import type {
|
||||
SecurityAlertEntityConfig,
|
||||
SecurityAlertSeverity,
|
||||
} from "../../../data/frontend";
|
||||
import {
|
||||
internationalizationContext,
|
||||
registriesContext,
|
||||
statesContext,
|
||||
} from "../../../data/context";
|
||||
import "../../../components/entity/ha-entity-picker";
|
||||
import "../../../components/entity/state-badge";
|
||||
import "../../../components/ha-control-select-menu";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-settings-row";
|
||||
import "../../../components/ha-sortable";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import { computeDefaultSecurityAlertSeverity } from "../strategies/security-alerts";
|
||||
import {
|
||||
createSecurityEntityFilter,
|
||||
type SecurityEntityContext,
|
||||
} from "../security-entity-filter";
|
||||
import type { ValueChangedEvent } from "../../../types";
|
||||
|
||||
type SecurityEditorContext = SecurityEntityContext & {
|
||||
language: ContextType<typeof internationalizationContext>["language"];
|
||||
translationMetadata: ContextType<
|
||||
typeof internationalizationContext
|
||||
>["translationMetadata"];
|
||||
};
|
||||
|
||||
@customElement("security-alerts-editor")
|
||||
export class SecurityAlertsEditor extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public alertEntities: SecurityAlertEntityConfig[] = [];
|
||||
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: registriesContext, subscribe: true })
|
||||
private _registries!: ContextType<typeof registriesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
private _entityContext?: SecurityEditorContext;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (
|
||||
changedProps.has("_states") ||
|
||||
changedProps.has("_registries") ||
|
||||
changedProps.has("_i18n")
|
||||
) {
|
||||
this._entityContext = {
|
||||
states: this._states,
|
||||
...this._registries,
|
||||
language: this._i18n.language,
|
||||
translationMetadata: this._i18n.translationMetadata,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-sortable handle-selector=".handle" @item-moved=${this._moved}>
|
||||
<div class="alert-list">
|
||||
${repeat(
|
||||
this.alertEntities,
|
||||
(alertEntity) => alertEntity.entity,
|
||||
(alertEntity, index) => this._renderAlertEntity(alertEntity, index)
|
||||
)}
|
||||
</div>
|
||||
</ha-sortable>
|
||||
<ha-entity-picker
|
||||
add-button
|
||||
.addButtonLabel=${this._i18n.localize(
|
||||
"ui.panel.security.editor.add_alert_entity"
|
||||
)}
|
||||
.excludeEntities=${this.alertEntities.map(({ entity }) => entity)}
|
||||
.entityFilter=${this._entityFilter}
|
||||
@value-changed=${this._add}
|
||||
></ha-entity-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderAlertEntity(
|
||||
alertEntity: SecurityAlertEntityConfig,
|
||||
index: number
|
||||
) {
|
||||
const stateObj = this._states[alertEntity.entity];
|
||||
const { primary, secondary } =
|
||||
stateObj && this._entityContext
|
||||
? computeEntityPickerDisplay(this._entityContext, stateObj)
|
||||
: { primary: alertEntity.entity, secondary: undefined };
|
||||
const severity =
|
||||
alertEntity.severity ?? computeDefaultSecurityAlertSeverity(stateObj);
|
||||
|
||||
return html`
|
||||
<div class="alert-row">
|
||||
<div class="handle">
|
||||
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
|
||||
</div>
|
||||
<ha-settings-row slim>
|
||||
<state-badge slot="prefix" .stateObj=${stateObj}></state-badge>
|
||||
<span slot="heading">${primary}</span>
|
||||
${
|
||||
secondary
|
||||
? html`<span slot="description">${secondary}</span>`
|
||||
: nothing
|
||||
}
|
||||
<ha-control-select-menu
|
||||
show-arrow
|
||||
hide-label
|
||||
.label=${this._i18n.localize(
|
||||
"ui.panel.security.editor.severity.label"
|
||||
)}
|
||||
.value=${severity}
|
||||
.options=${(["alert", "warning"] as const).map((option) => ({
|
||||
value: option,
|
||||
label: this._i18n.localize(
|
||||
`ui.panel.security.editor.severity.${option}`
|
||||
),
|
||||
}))}
|
||||
data-index=${index}
|
||||
@wa-select=${this._severityChanged}
|
||||
></ha-control-select-menu>
|
||||
<ha-icon-button
|
||||
.path=${mdiDelete}
|
||||
.label=${this._i18n.localize("ui.common.delete")}
|
||||
data-index=${index}
|
||||
@click=${this._removeClicked}
|
||||
></ha-icon-button>
|
||||
</ha-settings-row>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _changed(next: SecurityAlertEntityConfig[]): void {
|
||||
fireEvent(this, "value-changed", { value: next });
|
||||
}
|
||||
|
||||
private _entityFilter = createSecurityEntityFilter(() => this._entityContext);
|
||||
|
||||
private _getIndex(
|
||||
ev: HASSDomCurrentTargetEvent<HTMLElement>
|
||||
): number | undefined {
|
||||
const index = Number(ev.currentTarget.dataset.index);
|
||||
return Number.isInteger(index) ? index : undefined;
|
||||
}
|
||||
|
||||
private _removeClicked(ev: HASSDomCurrentTargetEvent<HTMLElement>): void {
|
||||
ev.stopPropagation();
|
||||
const index = this._getIndex(ev);
|
||||
if (index !== undefined) {
|
||||
const next = [...this.alertEntities];
|
||||
next.splice(index, 1);
|
||||
this._changed(next);
|
||||
}
|
||||
}
|
||||
|
||||
private _severityChanged(
|
||||
ev: HaDropdownSelectEvent<SecurityAlertSeverity> &
|
||||
HASSDomCurrentTargetEvent<HTMLElement>
|
||||
): void {
|
||||
ev.stopPropagation();
|
||||
const index = this._getIndex(ev);
|
||||
if (index === undefined) {
|
||||
return;
|
||||
}
|
||||
const next = [...this.alertEntities];
|
||||
next[index] = { ...next[index], severity: ev.detail.item.value };
|
||||
this._changed(next);
|
||||
}
|
||||
|
||||
private _add(
|
||||
ev: ValueChangedEvent<string | undefined> &
|
||||
HASSDomCurrentTargetEvent<HaEntityPicker>
|
||||
): void {
|
||||
ev.stopPropagation();
|
||||
const entity = ev.detail.value;
|
||||
if (!entity) return;
|
||||
|
||||
ev.currentTarget.value = "";
|
||||
|
||||
if (
|
||||
this.alertEntities.some((alertEntity) => alertEntity.entity === entity)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._changed([...this.alertEntities, { entity }]);
|
||||
}
|
||||
|
||||
private _moved(ev: HASSDomEvent<HASSDomEvents["item-moved"]>): void {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
const next = [...this.alertEntities];
|
||||
const [moved] = next.splice(oldIndex, 1);
|
||||
next.splice(newIndex, 0, moved);
|
||||
this._changed(next);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.alert-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.alert-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.handle {
|
||||
cursor: grab;
|
||||
color: var(--secondary-text-color);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
}
|
||||
ha-settings-row {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
gap: var(--ha-space-3);
|
||||
min-height: 48px;
|
||||
--settings-row-prefix-display: contents;
|
||||
--settings-row-content-display: contents;
|
||||
--settings-row-body-padding-top: var(--ha-space-1);
|
||||
--settings-row-body-padding-bottom: var(--ha-space-1);
|
||||
}
|
||||
state-badge {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
--state-icon-color: var(--secondary-text-color);
|
||||
}
|
||||
[slot="heading"],
|
||||
[slot="description"] {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
ha-entity-picker {
|
||||
display: block;
|
||||
padding-top: var(--ha-space-3);
|
||||
}
|
||||
ha-icon-button {
|
||||
--ha-icon-button-size: 40px;
|
||||
}
|
||||
ha-control-select-menu {
|
||||
width: 118px;
|
||||
--control-select-menu-height: 42px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"security-alerts-editor": SecurityAlertsEditor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dialog";
|
||||
import "../../../components/ha-dialog-footer";
|
||||
import "../../../components/ha-expansion-panel";
|
||||
import "../../../components/ha-icon";
|
||||
import type { SecurityFrontendSystemData } from "../../../data/frontend";
|
||||
import {
|
||||
internationalizationContext,
|
||||
registriesContext,
|
||||
statesContext,
|
||||
} from "../../../data/context";
|
||||
import { DialogMixin } from "../../../dialogs/dialog-mixin";
|
||||
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { ValueChangedEvent } from "../../../types";
|
||||
import "../../../components/entity/ha-favorites-editor";
|
||||
import "../components/security-alerts-editor";
|
||||
import {
|
||||
createSecurityEntityFilter,
|
||||
type SecurityEntityContext,
|
||||
} from "../security-entity-filter";
|
||||
import type { EditSecurityDialogParams } from "./show-dialog-edit-security";
|
||||
|
||||
@customElement("dialog-edit-security")
|
||||
export class DialogEditSecurity extends DirtyStateProviderMixin<SecurityFrontendSystemData>()(
|
||||
DialogMixin<EditSecurityDialogParams>(LitElement)
|
||||
) {
|
||||
@state() private _state?: SecurityFrontendSystemData;
|
||||
|
||||
@state() private _submitting = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: registriesContext, subscribe: true })
|
||||
private _registries!: ContextType<typeof registriesContext>;
|
||||
|
||||
private _entityContext?: SecurityEntityContext;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("_states") || changedProps.has("_registries")) {
|
||||
this._entityContext = {
|
||||
states: this._states,
|
||||
...this._registries,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (!this.params) {
|
||||
return;
|
||||
}
|
||||
this._state = {
|
||||
...this.params.config,
|
||||
favorite_entities: this.params.config.favorite_entities
|
||||
? [...this.params.config.favorite_entities]
|
||||
: [],
|
||||
alert_entities: this.params.config.alert_entities
|
||||
? [...this.params.config.alert_entities]
|
||||
: [],
|
||||
};
|
||||
this._initDirtyTracking({ type: "deep" }, this._state);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.params || !this._state) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
width="medium"
|
||||
.headerTitle=${this._i18n.localize("ui.panel.security.editor.title")}
|
||||
.headerSubtitle=${this._i18n.localize(
|
||||
"ui.panel.security.editor.description"
|
||||
)}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
>
|
||||
${this._renderMainEditor()}
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
autofocus
|
||||
appearance="plain"
|
||||
slot="secondaryAction"
|
||||
@click=${this.closeDialog}
|
||||
.disabled=${this._submitting}
|
||||
>
|
||||
${this._i18n.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._save}
|
||||
.disabled=${this._submitting || !this.isDirtyState}
|
||||
>
|
||||
${this._i18n.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderMainEditor() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
outlined
|
||||
expanded
|
||||
no-collapse
|
||||
.header=${this._i18n.localize(
|
||||
"ui.panel.security.editor.favorite_entities"
|
||||
)}
|
||||
.secondary=${this._i18n.localize(
|
||||
"ui.panel.security.editor.favorite_entities_description"
|
||||
)}
|
||||
>
|
||||
<ha-icon slot="leading-icon" icon="mdi:star-outline"></ha-icon>
|
||||
<div class="expansion-content">
|
||||
<ha-favorites-editor
|
||||
.favorites=${this._state?.favorite_entities ?? []}
|
||||
.entityFilter=${this._entityFilter}
|
||||
.addButtonLabel=${this._i18n.localize(
|
||||
"ui.panel.security.editor.add_favorite_entity"
|
||||
)}
|
||||
@value-changed=${this._favoriteEntitiesChanged}
|
||||
></ha-favorites-editor>
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
<ha-expansion-panel
|
||||
outlined
|
||||
expanded
|
||||
no-collapse
|
||||
.header=${this._i18n.localize(
|
||||
"ui.panel.security.editor.active_alert_entities"
|
||||
)}
|
||||
.secondary=${this._i18n.localize(
|
||||
"ui.panel.security.editor.active_alert_entities_description"
|
||||
)}
|
||||
>
|
||||
<ha-icon slot="leading-icon" icon="mdi:shield-alert-outline"></ha-icon>
|
||||
<div class="expansion-content">
|
||||
<security-alerts-editor
|
||||
.alertEntities=${this._state?.alert_entities ?? []}
|
||||
@value-changed=${this._alertEntitiesChanged}
|
||||
></security-alerts-editor>
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
private _alertEntitiesChanged(
|
||||
ev: ValueChangedEvent<SecurityFrontendSystemData["alert_entities"]>
|
||||
): void {
|
||||
this._state = {
|
||||
...this._state,
|
||||
alert_entities: ev.detail.value,
|
||||
};
|
||||
this._updateDirtyState(this._state);
|
||||
}
|
||||
|
||||
private _favoriteEntitiesChanged(
|
||||
ev: ValueChangedEvent<SecurityFrontendSystemData["favorite_entities"]>
|
||||
): void {
|
||||
this._state = {
|
||||
...this._state,
|
||||
favorite_entities: ev.detail.value,
|
||||
};
|
||||
this._updateDirtyState(this._state);
|
||||
}
|
||||
|
||||
private _entityFilter = createSecurityEntityFilter(() => this._entityContext);
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this.params || !this._state) return;
|
||||
|
||||
this._submitting = true;
|
||||
|
||||
try {
|
||||
await this.params.saveConfig({
|
||||
...this.params.config,
|
||||
favorite_entities: this._state.favorite_entities?.length
|
||||
? this._state.favorite_entities
|
||||
: undefined,
|
||||
alert_entities: this._state.alert_entities?.length
|
||||
? this._state.alert_entities
|
||||
: undefined,
|
||||
});
|
||||
this._markDirtyStateClean();
|
||||
this.closeDialog();
|
||||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
this._submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-dialog {
|
||||
--dialog-content-padding: var(--ha-space-6);
|
||||
}
|
||||
|
||||
ha-expansion-panel {
|
||||
display: block;
|
||||
--expansion-panel-content-padding: 0;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
--ha-card-border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
|
||||
ha-expansion-panel + ha-expansion-panel {
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.expansion-content {
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-edit-security": DialogEditSecurity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import type { SecurityFrontendSystemData } from "../../../data/frontend";
|
||||
|
||||
export interface EditSecurityDialogParams {
|
||||
config: SecurityFrontendSystemData;
|
||||
saveConfig: (config: SecurityFrontendSystemData) => Promise<void>;
|
||||
}
|
||||
|
||||
export const loadEditSecurityDialog = () => import("./dialog-edit-security");
|
||||
|
||||
export const showEditSecurityDialog = (
|
||||
element: HTMLElement,
|
||||
params: EditSecurityDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-edit-security",
|
||||
dialogImport: loadEditSecurityDialog,
|
||||
dialogParams: params,
|
||||
});
|
||||
};
|
||||
@@ -1,15 +1,24 @@
|
||||
import { mdiPencil } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/ha-icon-button";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import {
|
||||
fetchFrontendSystemData,
|
||||
saveFrontendSystemData,
|
||||
type SecurityFrontendSystemData,
|
||||
} from "../../data/frontend";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { showToast } from "../../util/toast";
|
||||
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
|
||||
import type { Lovelace } from "../lovelace/types";
|
||||
import { showEditSecurityDialog } from "./dialogs/show-dialog-edit-security";
|
||||
import "../lovelace/views/hui-view";
|
||||
import "../lovelace/views/hui-view-container";
|
||||
import "../lovelace/views/hui-view-background";
|
||||
@@ -30,10 +39,16 @@ class PanelSecurity extends LitElement {
|
||||
|
||||
@state() private _lovelace?: Lovelace;
|
||||
|
||||
@state() private _config?: SecurityFrontendSystemData;
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
private _loadConfigPromise?: Promise<void>;
|
||||
|
||||
private _loadConfigRevision = 0;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -47,13 +62,22 @@ class PanelSecurity extends LitElement {
|
||||
}
|
||||
|
||||
const oldHass = changedProps.get("hass") as this["hass"];
|
||||
if (
|
||||
oldHass &&
|
||||
this.hass.config.state === "RUNNING" &&
|
||||
oldHass.config.state !== "RUNNING"
|
||||
) {
|
||||
this._setup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldHass && oldHass.localize !== this.hass.localize) {
|
||||
this._setLovelace();
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldHass && this.hass) {
|
||||
// If the entity registry changed, ask the user if they want to refresh the config
|
||||
// Refresh the generated view when registries or panels change.
|
||||
if (
|
||||
oldHass.entities !== this.hass.entities ||
|
||||
oldHass.devices !== this.hass.devices ||
|
||||
@@ -63,22 +87,44 @@ class PanelSecurity extends LitElement {
|
||||
) {
|
||||
if (this.hass.config.state === "RUNNING") {
|
||||
this._debounceRegistriesChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// If ha started, refresh the config
|
||||
if (
|
||||
this.hass.config.state === "RUNNING" &&
|
||||
oldHass.config.state !== "RUNNING"
|
||||
) {
|
||||
this._setLovelace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _setup() {
|
||||
await this.hass.loadFragmentTranslation("lovelace");
|
||||
this._setLovelace();
|
||||
this._loadConfigPromise = this._loadConfig();
|
||||
await Promise.all([
|
||||
this.hass.loadFragmentTranslation("lovelace"),
|
||||
this._loadConfigPromise,
|
||||
]);
|
||||
await this._setLovelace();
|
||||
}
|
||||
|
||||
private async _loadConfig() {
|
||||
const revision = ++this._loadConfigRevision;
|
||||
this._config = undefined;
|
||||
try {
|
||||
const data = await fetchFrontendSystemData(
|
||||
this.hass.connection,
|
||||
"security"
|
||||
);
|
||||
if (revision !== this._loadConfigRevision) {
|
||||
return;
|
||||
}
|
||||
this._config = data || {};
|
||||
} catch (err) {
|
||||
if (revision !== this._loadConfigRevision) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to load security configuration:", err);
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.panel.security.editor.load_failed"),
|
||||
duration: 0,
|
||||
dismissable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _debounceRegistriesChanged = debounce(
|
||||
@@ -97,6 +143,16 @@ class PanelSecurity extends LitElement {
|
||||
.backButton=${this._searchParms.has("historyBack")}
|
||||
>
|
||||
<div slot="title">${this.hass.localize("panel.security")}</div>
|
||||
${
|
||||
this.hass.user?.is_admin && this._config
|
||||
? html`<ha-icon-button
|
||||
slot="actionItems"
|
||||
.path=${mdiPencil}
|
||||
.label=${this.hass.localize("ui.panel.security.editor.title")}
|
||||
@click=${this._editSecurity}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._lovelace
|
||||
? html`
|
||||
@@ -118,8 +174,18 @@ class PanelSecurity extends LitElement {
|
||||
}
|
||||
|
||||
private async _setLovelace() {
|
||||
if (this._loadConfigPromise) {
|
||||
await this._loadConfigPromise;
|
||||
}
|
||||
|
||||
const viewConfig = await generateLovelaceViewStrategy(
|
||||
SECURITY_LOVELACE_VIEW_CONFIG,
|
||||
{
|
||||
strategy: {
|
||||
...SECURITY_LOVELACE_VIEW_CONFIG.strategy,
|
||||
alert_entities: this._config?.alert_entities,
|
||||
favorite_entities: this._config?.favorite_entities,
|
||||
},
|
||||
},
|
||||
this.hass
|
||||
);
|
||||
|
||||
@@ -146,6 +212,38 @@ class PanelSecurity extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
private _editSecurity = () => {
|
||||
if (!this.hass.user?.is_admin || !this._config) {
|
||||
return;
|
||||
}
|
||||
showEditSecurityDialog(this, {
|
||||
config: this._config,
|
||||
saveConfig: async (config) => {
|
||||
await this._saveConfig(config);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private async _saveConfig(config: SecurityFrontendSystemData): Promise<void> {
|
||||
try {
|
||||
await saveFrontendSystemData(this.hass.connection, "security", config);
|
||||
this._config = config;
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to save security configuration:", err);
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.panel.security.editor.save_failed"),
|
||||
duration: 0,
|
||||
dismissable: true,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.common.successfully_saved"),
|
||||
});
|
||||
await this._setLovelace();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { registriesContext, statesContext } from "../../data/context";
|
||||
import { isSecurityPanelEntity } from "./strategies/security-view-strategy";
|
||||
|
||||
export type SecurityEntityContext = ContextType<typeof registriesContext> & {
|
||||
states: ContextType<typeof statesContext>;
|
||||
};
|
||||
|
||||
export const createSecurityEntityFilter =
|
||||
(getContext: () => SecurityEntityContext | undefined) =>
|
||||
(entity: HassEntity): boolean => {
|
||||
const context = getContext();
|
||||
return context ? isSecurityPanelEntity(context, entity) : false;
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import type {
|
||||
SecurityAlertEntityConfig,
|
||||
SecurityAlertSeverity,
|
||||
} from "../../../data/frontend";
|
||||
import type { StateCondition } from "../../lovelace/common/validate-condition";
|
||||
import type { AlertCardConfig } from "../../lovelace/cards/types";
|
||||
|
||||
const DANGER_BINARY_SENSOR_DEVICE_CLASSES = [
|
||||
"carbon_monoxide",
|
||||
"gas",
|
||||
"moisture",
|
||||
"safety",
|
||||
"smoke",
|
||||
] as const;
|
||||
|
||||
type DangerBinarySensorDeviceClass =
|
||||
(typeof DANGER_BINARY_SENSOR_DEVICE_CLASSES)[number];
|
||||
|
||||
const DANGER_BINARY_SENSOR_DEVICE_CLASS_SET =
|
||||
new Set<DangerBinarySensorDeviceClass>(DANGER_BINARY_SENSOR_DEVICE_CLASSES);
|
||||
|
||||
const isDangerBinarySensorDeviceClass = (
|
||||
deviceClass: string
|
||||
): deviceClass is DangerBinarySensorDeviceClass =>
|
||||
DANGER_BINARY_SENSOR_DEVICE_CLASS_SET.has(
|
||||
deviceClass as DangerBinarySensorDeviceClass
|
||||
);
|
||||
|
||||
export const computeDefaultSecurityAlertSeverity = (
|
||||
stateObj?: HassEntity
|
||||
): SecurityAlertSeverity => {
|
||||
if (!stateObj) {
|
||||
return "warning";
|
||||
}
|
||||
|
||||
const domain = computeDomain(stateObj.entity_id);
|
||||
if (domain === "alarm_control_panel") {
|
||||
return "alert";
|
||||
}
|
||||
if (domain === "binary_sensor") {
|
||||
const deviceClass = stateObj.attributes.device_class;
|
||||
return typeof deviceClass === "string" &&
|
||||
isDangerBinarySensorDeviceClass(deviceClass)
|
||||
? "alert"
|
||||
: "warning";
|
||||
}
|
||||
return "warning";
|
||||
};
|
||||
|
||||
export const computeDefaultSecurityAlertVisibility = (
|
||||
entityId: string
|
||||
): StateCondition[] => {
|
||||
const condition: StateCondition = {
|
||||
condition: "state",
|
||||
entity: entityId,
|
||||
};
|
||||
|
||||
switch (computeDomain(entityId)) {
|
||||
case "alarm_control_panel":
|
||||
condition.state = "triggered";
|
||||
break;
|
||||
case "cover":
|
||||
condition.state = "open";
|
||||
break;
|
||||
case "lock":
|
||||
condition.state = ["jammed", "unlocked", "open"];
|
||||
break;
|
||||
default:
|
||||
condition.state = "on";
|
||||
}
|
||||
|
||||
return [condition];
|
||||
};
|
||||
|
||||
export const computeSecurityAlertCardConfig = (
|
||||
stateObj: HassEntity | undefined,
|
||||
alertEntity: SecurityAlertEntityConfig
|
||||
): AlertCardConfig => {
|
||||
const severity =
|
||||
alertEntity.severity ?? computeDefaultSecurityAlertSeverity(stateObj);
|
||||
return {
|
||||
type: "alert",
|
||||
entity: alertEntity.entity,
|
||||
color: severity === "alert" ? "red" : "amber",
|
||||
visibility: computeDefaultSecurityAlertVisibility(alertEntity.entity),
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
|
||||
@@ -15,12 +16,19 @@ import type {
|
||||
LovelaceSectionRawConfig,
|
||||
} from "../../../data/lovelace/config/section";
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LogbookCardConfig } from "../../lovelace/cards/types";
|
||||
import type {
|
||||
LogbookCardConfig,
|
||||
TileCardConfig,
|
||||
} from "../../lovelace/cards/types";
|
||||
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
|
||||
import { computeSecurityAlertCardConfig } from "./security-alerts";
|
||||
|
||||
export interface SecurityViewStrategyConfig {
|
||||
type: "security";
|
||||
alert_entities?: SecurityAlertEntityConfig[];
|
||||
favorite_entities?: string[];
|
||||
}
|
||||
|
||||
export const securityEntityFilters: EntityFilter[] = [
|
||||
@@ -69,6 +77,17 @@ export const securityEntityFilters: EntityFilter[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const isSecurityPanelEntity = (
|
||||
hass: Pick<
|
||||
HomeAssistant,
|
||||
"states" | "entities" | "devices" | "areas" | "floors"
|
||||
>,
|
||||
stateObj: HassEntity
|
||||
): boolean =>
|
||||
securityEntityFilters.some((filter) =>
|
||||
generateEntityFilter(hass, filter)(stateObj.entity_id)
|
||||
);
|
||||
|
||||
const processAreasForSecurity = (
|
||||
areaIds: string[],
|
||||
hass: HomeAssistant,
|
||||
@@ -132,7 +151,7 @@ const processUnassignedEntities = (
|
||||
@customElement("security-view-strategy")
|
||||
export class SecurityViewStrategy extends ReactiveElement {
|
||||
static async generate(
|
||||
_config: SecurityViewStrategyConfig,
|
||||
config: SecurityViewStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
): Promise<LovelaceViewConfig> {
|
||||
const areas = Object.values(hass.areas);
|
||||
@@ -149,6 +168,37 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
|
||||
const entities = findEntities(allEntities, securityFilters);
|
||||
|
||||
const favoriteEntities = (config.favorite_entities ?? []).filter(
|
||||
(entityId) =>
|
||||
hass.states[entityId] &&
|
||||
isSecurityPanelEntity(hass, hass.states[entityId])
|
||||
);
|
||||
|
||||
if (favoriteEntities.length > 0) {
|
||||
sections.push({
|
||||
type: "grid",
|
||||
column_span: 2,
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.favorites"
|
||||
),
|
||||
heading_style: "title",
|
||||
},
|
||||
...favoriteEntities.map(
|
||||
(entityId) =>
|
||||
({
|
||||
type: "tile",
|
||||
entity: entityId,
|
||||
state_content: ["state", "area_name"],
|
||||
show_entity_picture: true,
|
||||
}) satisfies TileCardConfig
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const floorCount =
|
||||
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
|
||||
|
||||
@@ -242,37 +292,87 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
|
||||
const logbookEntityIds = [...entities, ...personEntities];
|
||||
|
||||
const sidebarSection: LovelaceSectionConfig | undefined =
|
||||
hasLogbook && logbookEntityIds.length > 0
|
||||
? {
|
||||
type: "grid",
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.activity"
|
||||
),
|
||||
heading_style: "title",
|
||||
} as LovelaceCardConfig,
|
||||
{
|
||||
type: "logbook",
|
||||
target: {
|
||||
entity_id: logbookEntityIds,
|
||||
},
|
||||
hours_to_show: 24,
|
||||
grid_options: { columns: 12 },
|
||||
} satisfies LogbookCardConfig,
|
||||
],
|
||||
}
|
||||
: undefined;
|
||||
const sidebarSections: LovelaceSectionConfig[] = [];
|
||||
|
||||
const alertCards = config.alert_entities?.map((alertEntity) =>
|
||||
computeSecurityAlertCardConfig(
|
||||
hass.states[alertEntity.entity],
|
||||
alertEntity
|
||||
)
|
||||
);
|
||||
|
||||
if (alertCards?.length) {
|
||||
sidebarSections.push({
|
||||
type: "grid",
|
||||
visibility: [
|
||||
{
|
||||
condition: "or",
|
||||
conditions: alertCards.map((alertCard) => ({
|
||||
condition: "and",
|
||||
conditions: alertCard.visibility!,
|
||||
})),
|
||||
},
|
||||
],
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.active_alerts"
|
||||
),
|
||||
heading_style: "title",
|
||||
},
|
||||
...alertCards.map((alertCard) => ({
|
||||
...alertCard,
|
||||
grid_options: { columns: 12 },
|
||||
})),
|
||||
] satisfies LovelaceCardConfig[],
|
||||
});
|
||||
}
|
||||
|
||||
const hasLogbookSection = hasLogbook && logbookEntityIds.length > 0;
|
||||
if (hasLogbookSection) {
|
||||
sidebarSections.push({
|
||||
type: "grid",
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.activity"
|
||||
),
|
||||
heading_style: "title",
|
||||
} as LovelaceCardConfig,
|
||||
{
|
||||
type: "logbook",
|
||||
target: {
|
||||
entity_id: logbookEntityIds,
|
||||
},
|
||||
hours_to_show: 24,
|
||||
grid_options: { columns: 12 },
|
||||
} satisfies LogbookCardConfig,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: "sections",
|
||||
max_columns: 3,
|
||||
sections: sections,
|
||||
...(sidebarSection && {
|
||||
...(sidebarSections.length > 0 && {
|
||||
sidebar: {
|
||||
sections: [sidebarSection],
|
||||
sections: sidebarSections,
|
||||
...(!hasLogbookSection && alertCards?.length
|
||||
? {
|
||||
visibility: [
|
||||
{
|
||||
condition: "or" as const,
|
||||
conditions: alertCards.map((alertCard) => ({
|
||||
condition: "and" as const,
|
||||
conditions: alertCard.visibility!,
|
||||
})),
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
content_label: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.devices"
|
||||
),
|
||||
|
||||
+63
-35
@@ -806,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}",
|
||||
@@ -835,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",
|
||||
@@ -930,6 +934,12 @@
|
||||
"recommended": "Recommended"
|
||||
}
|
||||
},
|
||||
"device-class-picker": {
|
||||
"device_class": "Device class",
|
||||
"add": "Add device class",
|
||||
"no_device_classes": "No device classes available",
|
||||
"no_match": "No device classes found for {term}"
|
||||
},
|
||||
"category-picker": {
|
||||
"clear": "Clear",
|
||||
"show_categories": "Show categories",
|
||||
@@ -2636,6 +2646,25 @@
|
||||
"learn_more": "Learn more"
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"editor": {
|
||||
"title": "Edit security and safety page",
|
||||
"description": "Configure your security and safety display preferences.",
|
||||
"favorite_entities": "[%key:ui::panel::lovelace::editor::strategy::home::favorite_entities%]",
|
||||
"favorite_entities_description": "Pin entities to the top of the page.",
|
||||
"add_favorite_entity": "[%key:ui::panel::lovelace::editor::strategy::home::add_favorite_entity%]",
|
||||
"active_alert_entities": "Active alert entities",
|
||||
"active_alert_entities_description": "Display any entities that require attention.",
|
||||
"add_alert_entity": "Add entity",
|
||||
"severity": {
|
||||
"label": "Severity",
|
||||
"alert": "Alert",
|
||||
"warning": "Warning"
|
||||
},
|
||||
"load_failed": "Failed to load security and safety page configuration",
|
||||
"save_failed": "Failed to save security and safety page configuration"
|
||||
}
|
||||
},
|
||||
"my": {
|
||||
"not_supported": "This redirect is not supported by your Home Assistant instance. Check the {link} for the supported redirects and the version they where introduced.",
|
||||
"component_not_loaded": "This redirect is not supported by your Home Assistant instance. You need the integration {integration} to use this redirect.",
|
||||
@@ -5382,7 +5411,6 @@
|
||||
"any_state_ignore_attributes": "Any state (ignoring attribute changes)",
|
||||
"description": {
|
||||
"picker": "Triggers when the state of an entity (or attribute) changes.",
|
||||
"full": "When{hasAttribute, select, \n true { {attribute} of} \n other {}\n} {hasEntity, select, \n true {{entity}} \n other {something}\n} changes{fromChoice, select, \n fromUsed { from {fromString}}\n null { from any state} \n other {}\n}{toChoice, select, \n toUsed { to {toString}} \n null { to any state} \n special { state or any attributes} \n other {}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n}",
|
||||
"changed": "{hasAttribute, select, \n true {{attribute}} \n other {State}\n}{anyChange, select, \n true { or any attribute} \n other {}\n} changed{fromChoice, select, \n fromUsed { from {fromString}}\n null { from any state} \n other {}\n}{toChoice, select, \n toUsed { to {toString}} \n null { to any state} \n other {}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n}"
|
||||
},
|
||||
"for_type": {
|
||||
@@ -5423,9 +5451,6 @@
|
||||
"type_input": "Value of an entity",
|
||||
"description": {
|
||||
"picker": "Triggers when the numeric value of an entity''s state (or attribute''s value) crosses a given threshold.",
|
||||
"above": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above}{duration, select, \n undefined {} \n other { for {duration}}\n }",
|
||||
"below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }",
|
||||
"above-below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }",
|
||||
"crossed_above": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} crossed above {above}{duration, select, \n undefined {} \n other { for {duration}}\n}",
|
||||
"crossed_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} crossed below {below}{duration, select, \n undefined {} \n other { for {duration}}\n}",
|
||||
"crossed_above_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} crossed above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n}"
|
||||
@@ -5680,9 +5705,6 @@
|
||||
"value_template": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::value_template%]",
|
||||
"description": {
|
||||
"picker": "Tests if the numeric value of an entity's state (or attribute's value) is above or below a given threshold.",
|
||||
"above": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above}",
|
||||
"below": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} below {below}",
|
||||
"above-below": "If {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {are}\n} above {above} and below {below}",
|
||||
"is_above": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} is above {above}",
|
||||
"is_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} is below {below}",
|
||||
"is_above_below": "{attribute, select, \n undefined {Numeric state} \n other {{attribute}}\n} is above {above} and below {below}"
|
||||
@@ -5701,8 +5723,6 @@
|
||||
"state": "[%key:ui::panel::config::automation::editor::conditions::type::state::label%]",
|
||||
"description": {
|
||||
"picker": "Tests if an entity (or attribute) is in a specific state.",
|
||||
"no_entity": "If state confirmed",
|
||||
"full": "If{hasAttribute, select, \n true { {attribute} of}\n other {}\n} {numberOfEntities, plural,\n =0 {an entity is}\n one {{entities} is}\n other {{entities} {matchAny, select,\n true {is}\n other {are}\n}}\n} {numberOfStates, plural,\n =0 {a state}\n other {{states}}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n }",
|
||||
"is": "{hasAttribute, select, \n true {{attribute}} \n other {State}\n} is {states}{hasDuration, select, \n true { for {duration}} \n other {}\n}"
|
||||
}
|
||||
},
|
||||
@@ -6032,7 +6052,7 @@
|
||||
},
|
||||
"check_condition": {
|
||||
"description": {
|
||||
"full": "Test {condition}"
|
||||
"full": "Test: {condition}"
|
||||
}
|
||||
},
|
||||
"set_conversation_response": {
|
||||
@@ -6415,12 +6435,25 @@
|
||||
"description_features": "Control home when away and integrate with Alexa and Google Assistant",
|
||||
"login": {
|
||||
"title": "Cloud login",
|
||||
"introduction": "Home Assistant Cloud provides you with a secure remote connection to your instance while away from home. It also allows you to connect with cloud-only services: Amazon Alexa and Google Assistant.",
|
||||
"introduction2": "This service is run by our partner ",
|
||||
"introduction2a": ", a company founded by the founders of Home Assistant.",
|
||||
"introduction3": "Home Assistant Cloud is a subscription service with a free one-month trial. No payment information necessary.",
|
||||
"learn_more_link": "Learn more about Home Assistant Cloud",
|
||||
"landing_title": "[%key:ui::panel::config::cloud::account::onboarding::ha_cloud_title%]",
|
||||
"hero_title": "Tools for your home, support for the Open Home",
|
||||
"hero_lead": "The easy, secure, and private way to get more out of Home Assistant, and the simplest way to fund its development.",
|
||||
"trial_note": "Try free for a month. No payment information required up front.",
|
||||
"partner_note": "This service is run by our partner {nabu_casa_link}, a company founded by the founders of Home Assistant.",
|
||||
"feature_remote_title": "Access from anywhere",
|
||||
"feature_remote_body": "Securely reach your Home Assistant from any device, with no port forwarding or VPN.",
|
||||
"feature_backup_title": "Backups kept safe",
|
||||
"feature_backup_body": "Your latest Home Assistant backup, stored off-site and encrypted, ready to restore your full system on first boot.",
|
||||
"feature_voice_control_title": "Native Alexa and Google support",
|
||||
"feature_voice_control_body": "Control every Home Assistant device through Amazon Alexa and Google Assistant, so the devices and routines you already rely on keep working. Set up in minutes, not hours.",
|
||||
"feature_voice_quality_title": "Faster speech for your own assistant",
|
||||
"feature_voice_quality_body": "More languages and higher accuracy for Home Assistant's privacy-first voice assistant.",
|
||||
"feature_companion_title": "Companion app",
|
||||
"feature_companion_body": "Reach your home from your phone anywhere, with location and sensor updates flowing back securely and no ports to open.",
|
||||
"feature_support_title": "Support Home Assistant",
|
||||
"feature_support_body": "Your subscription funds the Open Home Foundation, keeping Home Assistant independent with no ads or data harvesting.",
|
||||
"sign_in": "Sign in",
|
||||
"sign_in_lead": "Sign in to your Nabu Casa account.",
|
||||
"email": "Email",
|
||||
"email_error_msg": "Invalid email",
|
||||
"password": "Password",
|
||||
@@ -6430,15 +6463,11 @@
|
||||
"cancel": "Cancel",
|
||||
"submit": "Submit",
|
||||
"forgot_password": "Forgot password?",
|
||||
"start_trial": "Start your free 1-month trial",
|
||||
"trial_info": "No payment information necessary",
|
||||
"alert_password_change_required": "You need to change your password before logging in.",
|
||||
"alert_email_confirm_necessary": "You need to confirm your email before logging in.",
|
||||
"alert_mfa_code_required": "You need to enter your two-factor authentication code.",
|
||||
"alert_mfa_expired_or_not_started": "Multi-factor authentication expired, or not started. Please try again.",
|
||||
"alert_totp_code_invalid": "Invalid two-factor authentication code.",
|
||||
"cloud_pipeline_title": "Want to use Home Assistant Cloud for your voice assistant?",
|
||||
"cloud_pipeline_text": "We created a new assistant for you, using the superior text-to-speech and speech-to-text engines from Home Assistant Cloud. Would you like to set this assistant as the preferred assistant?"
|
||||
"alert_totp_code_invalid": "Invalid two-factor authentication code."
|
||||
},
|
||||
"forgot_password": {
|
||||
"title": "Forgot password",
|
||||
@@ -6450,17 +6479,9 @@
|
||||
"check_your_email": "Check your email for instructions on how to reset your password."
|
||||
},
|
||||
"register": {
|
||||
"title": "Register account",
|
||||
"headline": "Start your free trial",
|
||||
"headline": "Start free trial",
|
||||
"information": "Create an account to start your free one-month trial with Home Assistant Cloud. No payment information necessary.",
|
||||
"information2": "The trial will give you access to all the benefits of Home Assistant Cloud, including:",
|
||||
"feature_remote_control": "Control of Home Assistant away from home",
|
||||
"feature_google_home": "Integration with Google Assistant",
|
||||
"feature_amazon_alexa": "Integration with Amazon Alexa",
|
||||
"feature_webhook_apps": "Easy integration with webhook-based apps like OwnTracks",
|
||||
"information3": "This service is run by our partner ",
|
||||
"information3a": ", a company founded by the founders of Home Assistant.",
|
||||
"information4": "By registering an account, you agree to the following terms and conditions.",
|
||||
"terms_notice": "By creating an account, you agree to our {terms} and {privacy_policy}.",
|
||||
"link_terms_conditions": "Terms and conditions",
|
||||
"link_privacy_policy": "Privacy policy",
|
||||
"create_account": "Create account",
|
||||
@@ -6469,10 +6490,17 @@
|
||||
"password": "Password",
|
||||
"password_error_msg": "Passwords are at least 8 characters",
|
||||
"start_trial": "Start trial",
|
||||
"sign_in_instead": "Sign in instead",
|
||||
"check_your_email": "Check your email",
|
||||
"confirm_email_body": "Open the confirmation link in the email we just sent to {email}.",
|
||||
"waiting_for_confirmation": "Waiting for confirmation. We'll sign you in automatically.",
|
||||
"checking_confirmation": "Checking for your confirmation…",
|
||||
"auto_login_failed": "We couldn't sign you in automatically. Confirm your email, then sign in.",
|
||||
"nothing_arrived": "Nothing arrived? Check your spam folder, or {resend_link}.",
|
||||
"resend_link": "resend the confirmation email",
|
||||
"resend_confirm_email": "Resend confirmation email",
|
||||
"clicked_confirm": "I opened the confirmation link",
|
||||
"confirm_email": "Check the email we just sent to {email} and open the confirmation link to continue.",
|
||||
"account_created": "Account created! Check your email for instructions on how to activate your account.",
|
||||
"verification_email_sent": "Verification email sent. Check your inbox for instructions on how to activate your account."
|
||||
},
|
||||
"account": {
|
||||
@@ -9157,6 +9185,8 @@
|
||||
"security": {
|
||||
"devices": "Devices",
|
||||
"other_devices": "Other devices",
|
||||
"active_alerts": "Active alerts",
|
||||
"favorites": "[%key:ui::panel::lovelace::strategy::home::favorites%]",
|
||||
"activity": "Activity"
|
||||
},
|
||||
"climate": {
|
||||
@@ -11737,8 +11767,6 @@
|
||||
"cancel": "[%key:ui::panel::config::cloud::login::cancel%]",
|
||||
"submit": "[%key:ui::panel::config::cloud::login::submit%]",
|
||||
"forgot_password": "[%key:ui::panel::config::cloud::login::forgot_password%]",
|
||||
"start_trial": "[%key:ui::panel::config::cloud::login::start_trial%]",
|
||||
"trial_info": "[%key:ui::panel::config::cloud::login::trial_info%]",
|
||||
"alert_password_change_required": "[%key:ui::panel::config::cloud::login::alert_password_change_required%]",
|
||||
"alert_email_confirm_necessary": "[%key:ui::panel::config::cloud::login::alert_email_confirm_necessary%]",
|
||||
"alert_mfa_code_required": "[%key:ui::panel::config::cloud::login::alert_mfa_code_required%]",
|
||||
|
||||
@@ -17,10 +17,24 @@ const generatePoints = (seed: number, count: number): [number, number][] => {
|
||||
return points;
|
||||
};
|
||||
|
||||
// The chart data modules break the line with a null value. A handful of them
|
||||
// stands for an entity that went unavailable; a series that is mostly null
|
||||
// stands for the climate heating dataset, which emits one per inactive state.
|
||||
const withGaps = (
|
||||
points: [number, number][],
|
||||
isGap: (index: number) => boolean
|
||||
): [number, number | null][] =>
|
||||
points.map(([x, y], index) => (isGap(index) ? [x, null] : [x, y]));
|
||||
|
||||
const small = generatePoints(1, SCALES.small);
|
||||
const medium = generatePoints(2, SCALES.medium);
|
||||
const large = generatePoints(3, SCALES.large);
|
||||
const largeObjects = large.map((value) => ({ value }));
|
||||
const largeFewGaps = withGaps(large, (index) => index % 20_000 === 0);
|
||||
const largeMostlyGaps = withGaps(
|
||||
large,
|
||||
(index) => Math.floor(index / 50) % 3 !== 0
|
||||
);
|
||||
|
||||
describe("downSampleLineData", () => {
|
||||
bench("min/max small (1k points)", () => {
|
||||
@@ -54,4 +68,20 @@ describe("downSampleLineData", () => {
|
||||
},
|
||||
{ time: 1000, warmupIterations: 2 }
|
||||
);
|
||||
|
||||
bench(
|
||||
"min/max large with a few gaps (100k points)",
|
||||
() => {
|
||||
downSampleLineData(largeFewGaps, MAX_DETAILS);
|
||||
},
|
||||
{ time: 1000, warmupIterations: 2 }
|
||||
);
|
||||
|
||||
bench(
|
||||
"min/max large mostly gaps (100k points)",
|
||||
() => {
|
||||
downSampleLineData(largeMostlyGaps, MAX_DETAILS);
|
||||
},
|
||||
{ time: 1000, warmupIterations: 2 }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user