mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-01 06:40:03 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29b2a01299 |
@@ -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.
|
||||
- Each test added by the change protects real logic, not the look of a component.
|
||||
- Tests are added or updated for new data processing and utilities where applicable.
|
||||
- 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,13 +49,12 @@ 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.
|
||||
|
||||
## When To Add Tests
|
||||
## Unit And Utility Tests
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Dev Servers
|
||||
|
||||
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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`.
|
||||
+5
-6
@@ -1,8 +1,7 @@
|
||||
name: Sync sensor entity constants
|
||||
name: Sync numeric device classes
|
||||
|
||||
# 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
|
||||
# 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
|
||||
# when it drifts. Reads homeassistant/generated/sensor.json from core.
|
||||
|
||||
on:
|
||||
@@ -29,8 +28,8 @@ jobs:
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Regenerate sensor entity constants
|
||||
run: ./script/gen_sensor_entity_constants
|
||||
- name: Regenerate numeric device classes
|
||||
run: ./script/gen_numeric_device_classes
|
||||
|
||||
- name: Format
|
||||
run: yarn prettier --write src/data/sensor_numeric_device_classes.ts
|
||||
@@ -74,6 +74,3 @@ test/e2e/app/dist/
|
||||
.serena
|
||||
|
||||
test/benchmarks/results/
|
||||
|
||||
# Downloaded map glyph and sprite archives
|
||||
.map-assets/
|
||||
|
||||
@@ -33,7 +33,6 @@ 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
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import fs from "fs-extra";
|
||||
import gulp from "gulp";
|
||||
import path from "path";
|
||||
import paths from "../paths.cjs";
|
||||
import { ensureMapAssets, mapAssetsDir } from "./map-assets.js";
|
||||
|
||||
const npmPath = (...parts) =>
|
||||
path.resolve(paths.root_dir, "node_modules", ...parts);
|
||||
@@ -90,7 +89,7 @@ function copyQrScannerWorker(staticDir) {
|
||||
copyFileDir(npmPath("qr-scanner/qr-scanner-worker.min.js"), staticPath("js"));
|
||||
}
|
||||
|
||||
async function copyMapPanel(staticDir) {
|
||||
function copyMapPanel(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
copyFileDir(
|
||||
npmPath("leaflet/dist/leaflet.css"),
|
||||
@@ -104,14 +103,6 @@ async function copyMapPanel(staticDir) {
|
||||
npmPath("leaflet/dist/images"),
|
||||
staticPath("images/leaflet/images/")
|
||||
);
|
||||
|
||||
// Style, glyphs and sprites for the vector base map
|
||||
await ensureMapAssets();
|
||||
fs.copySync(mapAssetsDir, staticPath("map/"));
|
||||
copyFileDir(
|
||||
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
|
||||
staticPath("map/")
|
||||
);
|
||||
}
|
||||
|
||||
function copyZXingWasm(staticDir) {
|
||||
@@ -148,7 +139,7 @@ gulp.task("copy-static-app", async () => {
|
||||
copyMdiIcons(staticDir);
|
||||
|
||||
// Panel assets
|
||||
await copyMapPanel(staticDir);
|
||||
copyMapPanel(staticDir);
|
||||
|
||||
// Qr Scanner assets
|
||||
copyZXingWasm(staticDir);
|
||||
@@ -164,7 +155,7 @@ gulp.task("copy-static-demo", async () => {
|
||||
// Copy demo static files
|
||||
fs.copySync(path.resolve(paths.demo_dir, "public"), paths.demo_output_root);
|
||||
copyPolyfills(paths.demo_output_static);
|
||||
await copyMapPanel(paths.demo_output_static);
|
||||
copyMapPanel(paths.demo_output_static);
|
||||
copyFonts(paths.demo_output_static);
|
||||
copyTranslations(paths.demo_output_static);
|
||||
copyLocaleData(paths.demo_output_static);
|
||||
@@ -177,7 +168,7 @@ gulp.task("copy-static-cast", async () => {
|
||||
// Copy cast static files
|
||||
fs.copySync(path.resolve(paths.cast_dir, "public"), paths.cast_output_root);
|
||||
copyPolyfills(paths.cast_output_static);
|
||||
await copyMapPanel(paths.cast_output_static);
|
||||
copyMapPanel(paths.cast_output_static);
|
||||
copyFonts(paths.cast_output_static);
|
||||
copyTranslations(paths.cast_output_static);
|
||||
copyLocaleData(paths.cast_output_static);
|
||||
@@ -193,7 +184,7 @@ gulp.task("copy-static-gallery", async () => {
|
||||
paths.gallery_output_root
|
||||
);
|
||||
|
||||
await copyMapPanel(paths.gallery_output_static);
|
||||
copyMapPanel(paths.gallery_output_static);
|
||||
copyFonts(paths.gallery_output_static);
|
||||
copyTranslations(paths.gallery_output_static);
|
||||
copyLocaleData(paths.gallery_output_static);
|
||||
@@ -224,7 +215,7 @@ gulp.task("copy-static-e2e-test-app", async () => {
|
||||
}
|
||||
|
||||
copyPolyfills(paths.e2eTestApp_output_static);
|
||||
await copyMapPanel(paths.e2eTestApp_output_static);
|
||||
copyMapPanel(paths.e2eTestApp_output_static);
|
||||
copyFonts(paths.e2eTestApp_output_static);
|
||||
copyTranslations(paths.e2eTestApp_output_static);
|
||||
copyLocaleData(paths.e2eTestApp_output_static);
|
||||
|
||||
@@ -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/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);
|
||||
});
|
||||
@@ -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/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);
|
||||
});
|
||||
@@ -1,83 +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_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,12 +9,10 @@ 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-sensor-entity-constants.js";
|
||||
import "./gen-numeric-device-classes.js";
|
||||
import "./landing-page.js";
|
||||
import "./locale-data.js";
|
||||
import "./map-assets.js";
|
||||
import "./rspack.js";
|
||||
import "./service-worker.js";
|
||||
import "./translations.js";
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
// Assembles the static assets of the vector base map - style, SDF glyphs and
|
||||
// icon sprites - into /static/map/. vector.openstreetmap.org sets CORS headers
|
||||
// on its tiles only, so these cannot be loaded from there by a browser.
|
||||
//
|
||||
// Glyphs and sprites come from pinned VersaTiles releases, cached locally and
|
||||
// verified against a digest.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { colorful, eclipse } from "@versatiles/style";
|
||||
import fs from "fs-extra";
|
||||
import gulp from "gulp";
|
||||
import { extract } from "tar";
|
||||
import paths from "../paths.cjs";
|
||||
import { addLatinLabels } from "./map-labels.js";
|
||||
|
||||
// The tile URL is deliberately never named here: the OSMF asks consumers to
|
||||
// resolve it through the TileJSON so they can move the tiles without every
|
||||
// client needing a release. https://operations.osmfoundation.org/policies/vector/
|
||||
const TILEJSON_URL =
|
||||
"https://vector.openstreetmap.org/shortbread_v1/tilejson.json";
|
||||
|
||||
const ASSET_PATH = "/static/map";
|
||||
|
||||
const ARCHIVES = {
|
||||
fonts: {
|
||||
url: "https://github.com/versatiles-org/versatiles-fonts/releases/download/v2.2.0/noto_sans.tar.gz",
|
||||
sha256: "a2dac39f4096722bc420367ffd4a36687cce7229e8aa760bc12cf657072eea6b",
|
||||
},
|
||||
sprites: {
|
||||
url: "https://github.com/versatiles-org/versatiles-style/releases/download/v5.13.1/sprites.tar.gz",
|
||||
sha256: "efffd0ee4cb9591bd52f16ff5b269d9618c7dd1db159cd6511943965560ddea5",
|
||||
},
|
||||
};
|
||||
|
||||
// Rendered with a device font through `localIdeographFontFamily`, so these are
|
||||
// never downloaded - and they are 90% of the Noto Sans SDF set.
|
||||
const LOCAL_IDEOGRAPH_BLOCKS = [
|
||||
[0x2e80, 0x9fff], // CJK radicals through CJK Unified Ideographs, incl. kana
|
||||
[0xac00, 0xd7ff], // Hangul syllables
|
||||
[0xf900, 0xfaff], // CJK compatibility ideographs
|
||||
[0xfe30, 0xfe4f], // CJK compatibility forms
|
||||
];
|
||||
|
||||
// Our styles use bold only for motorway shields, so Latin, Greek and Cyrillic
|
||||
// cover every ref and the rest of bold - half the glyph set - is dropped.
|
||||
// `assertBoldStaysOnRefs` guards the assumption.
|
||||
const BOLD_MAX_CODEPOINT = 0x04ff;
|
||||
const BOLD_TEXT_FIELD = "{ref}";
|
||||
|
||||
const cacheDir = path.resolve(paths.root_dir, ".map-assets");
|
||||
const outputDir = path.resolve(paths.build_dir, "map");
|
||||
|
||||
const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex");
|
||||
|
||||
// Downloads an archive into the cache, or reuses it when the digest matches.
|
||||
const cachedArchive = async (name, { url, sha256: expected }) => {
|
||||
const file = path.join(cacheDir, `${name}.tar.gz`);
|
||||
|
||||
if (await fs.pathExists(file)) {
|
||||
if (sha256(await readFile(file)) === expected) {
|
||||
return file;
|
||||
}
|
||||
console.warn("Cached map %s archive is stale, downloading again", name);
|
||||
}
|
||||
|
||||
console.log("Downloading map %s from %s", name, url);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to download map ${name}: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
const body = Buffer.from(await response.arrayBuffer());
|
||||
|
||||
const digest = sha256(body);
|
||||
if (digest !== expected) {
|
||||
throw new Error(
|
||||
`Digest mismatch for map ${name}: expected ${expected}, got ${digest}`
|
||||
);
|
||||
}
|
||||
|
||||
await fs.outputFile(file, body);
|
||||
return file;
|
||||
};
|
||||
|
||||
const glyphRange = (entryPath) => {
|
||||
const match = /^(?<font>[^/]+)\/(?<start>\d+)-(?<end>\d+)\.pbf$/.exec(
|
||||
entryPath
|
||||
);
|
||||
return match
|
||||
? { font: match.groups.font, start: Number(match.groups.start) }
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const keepGlyph = (entryPath) => {
|
||||
const range = glyphRange(entryPath);
|
||||
if (!range) {
|
||||
return false;
|
||||
}
|
||||
if (range.font.endsWith("_bold") && range.start > BOLD_MAX_CODEPOINT) {
|
||||
return false;
|
||||
}
|
||||
return !LOCAL_IDEOGRAPH_BLOCKS.some(
|
||||
([from, to]) => range.start >= from && range.start <= to
|
||||
);
|
||||
};
|
||||
|
||||
const keepSprite = (entryPath) =>
|
||||
/^basics\/sprites(@2x)?\.(json|png)$/.test(entryPath);
|
||||
|
||||
// `neutrino`, for one, sets country and state labels in bold - names in any
|
||||
// script, which would turn to tofu. Fail the build rather than ship that.
|
||||
const assertBoldStaysOnRefs = (name, style) => {
|
||||
const offenders = style.layers
|
||||
.filter((layer) =>
|
||||
(layer.layout?.["text-font"] ?? []).some((font) => font.endsWith("_bold"))
|
||||
)
|
||||
.filter((layer) => layer.layout["text-field"] !== BOLD_TEXT_FIELD)
|
||||
.map((layer) => layer.id);
|
||||
|
||||
if (offenders.length) {
|
||||
throw new Error(
|
||||
`Style "${name}" uses bold for ${offenders.join(", ")}, which can hold ` +
|
||||
`names in any script. Raise BOLD_MAX_CODEPOINT to cover the full set ` +
|
||||
`before shipping this style.`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// MapLibre extends the fetched TileJSON with the style's source options, so
|
||||
// anything left here wins and freezes at build time. Dropping them is what lets
|
||||
// a remote switch move the attribution and zoom range too, not just the URLs.
|
||||
const TILEJSON_FIELDS = [
|
||||
"tiles",
|
||||
"attribution",
|
||||
"bounds",
|
||||
"minzoom",
|
||||
"maxzoom",
|
||||
"scheme",
|
||||
];
|
||||
|
||||
// The builder can only write a tile URL, so the source is repointed afterwards.
|
||||
// Keyed on there being exactly one source: any other shape means the builder's
|
||||
// own default host would ship unnoticed.
|
||||
const useTileJson = (name, style) => {
|
||||
const sources = Object.values(style.sources);
|
||||
|
||||
if (sources.length !== 1) {
|
||||
throw new Error(
|
||||
`Style "${name}" has ${sources.length} sources, expected exactly one to ` +
|
||||
`point at the TileJSON. Check what @versatiles/style emits.`
|
||||
);
|
||||
}
|
||||
|
||||
for (const field of TILEJSON_FIELDS) {
|
||||
delete sources[0][field];
|
||||
}
|
||||
sources[0].url = TILEJSON_URL;
|
||||
return style;
|
||||
};
|
||||
|
||||
const styleOptions = {
|
||||
// Keeps the generated URLs origin relative.
|
||||
baseUrl: "",
|
||||
glyphs: `${ASSET_PATH}/fonts/{fontstack}/{range}.pbf`,
|
||||
sprite: [{ id: "basics", url: `${ASSET_PATH}/sprites/basics/sprites` }],
|
||||
};
|
||||
|
||||
const buildMapAssets = async () => {
|
||||
const [fontArchive, spriteArchive] = await Promise.all([
|
||||
cachedArchive("fonts", ARCHIVES.fonts),
|
||||
cachedArchive("sprites", ARCHIVES.sprites),
|
||||
]);
|
||||
|
||||
await fs.emptyDir(outputDir);
|
||||
await Promise.all([
|
||||
fs.ensureDir(path.join(outputDir, "fonts")),
|
||||
fs.ensureDir(path.join(outputDir, "sprites")),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
extract({
|
||||
file: fontArchive,
|
||||
cwd: path.join(outputDir, "fonts"),
|
||||
filter: keepGlyph,
|
||||
}),
|
||||
extract({
|
||||
file: spriteArchive,
|
||||
cwd: path.join(outputDir, "sprites"),
|
||||
filter: keepSprite,
|
||||
}),
|
||||
// Both themes up front: dark is a real cartography, not an inverted raster.
|
||||
...[
|
||||
["light", colorful],
|
||||
["dark", eclipse],
|
||||
].map(([name, builder]) => {
|
||||
const style = addLatinLabels(useTileJson(name, builder(styleOptions)));
|
||||
assertBoldStaysOnRefs(name, style);
|
||||
return writeFile(
|
||||
path.join(outputDir, `${name}.json`),
|
||||
JSON.stringify(style)
|
||||
);
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
// Shared so it does not have to be wired into every pipeline separately.
|
||||
let pending;
|
||||
export const ensureMapAssets = () => {
|
||||
pending ??= buildMapAssets();
|
||||
return pending;
|
||||
};
|
||||
|
||||
gulp.task("build-map-assets", ensureMapAssets);
|
||||
|
||||
export const mapAssetsDir = outputDir;
|
||||
@@ -1,42 +0,0 @@
|
||||
// Adds the English name to labels whose local name is not in Latin script.
|
||||
// Shortbread tiles carry `name`, `name_en` and `name_de` only.
|
||||
|
||||
const NAME = ["get", "name"];
|
||||
const NAME_EN = ["get", "name_en"];
|
||||
|
||||
// Strings compare by code point: anything from Basic Latin up to Latin
|
||||
// Extended-B, digits and punctuation included.
|
||||
const IS_LATIN = ["<", NAME, "ɐ"];
|
||||
|
||||
const ENGLISH_SCALE = 0.8;
|
||||
|
||||
// Streets are line-placed and cannot break lines.
|
||||
const withEnglish = (placement) =>
|
||||
placement === "line"
|
||||
? ["concat", NAME, " (", NAME_EN, ")"]
|
||||
: ["format", NAME, {}, "\n", {}, NAME_EN, { "font-scale": ENGLISH_SCALE }];
|
||||
|
||||
const isNameLabel = (layer) =>
|
||||
JSON.stringify(layer.layout?.["text-field"]) === JSON.stringify(NAME);
|
||||
|
||||
export const addLatinLabels = (style) => ({
|
||||
...style,
|
||||
layers: style.layers.map((layer) =>
|
||||
isNameLabel(layer)
|
||||
? {
|
||||
...layer,
|
||||
layout: {
|
||||
...layer.layout,
|
||||
"text-field": [
|
||||
"case",
|
||||
IS_LATIN,
|
||||
NAME,
|
||||
["!", ["has", "name_en"]],
|
||||
NAME,
|
||||
withEnglish(layer.layout["symbol-placement"]),
|
||||
],
|
||||
},
|
||||
}
|
||||
: layer
|
||||
),
|
||||
});
|
||||
@@ -19,7 +19,6 @@ 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,23 +424,6 @@ 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,7 +39,6 @@ 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,
|
||||
|
||||
@@ -16,25 +16,6 @@ const ENTITIES = [
|
||||
duration: "0:05:00",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "timer.active_timer",
|
||||
state: "active",
|
||||
attributes: {
|
||||
friendly_name: "Active timer",
|
||||
duration: "0:10:00",
|
||||
remaining: "0:10:00",
|
||||
finishes_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "timer.paused_timer",
|
||||
state: "paused",
|
||||
attributes: {
|
||||
friendly_name: "Paused timer",
|
||||
duration: "0:10:00",
|
||||
remaining: "0:03:21",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-more-info-timer")
|
||||
|
||||
+15
-19
@@ -75,8 +75,6 @@
|
||||
"@lit/context": "1.1.6",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@lit/task": "1.0.3",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
|
||||
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"@material/web": "2.5.0",
|
||||
@@ -96,7 +94,7 @@
|
||||
"color-name": "2.1.1",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.50.0",
|
||||
"cropperjs": "1.6.3",
|
||||
"cropperjs": "1.6.2",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.4.0",
|
||||
"deep-clone-simple": "1.1.1",
|
||||
@@ -110,15 +108,14 @@
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.14",
|
||||
"js-yaml": "5.4.1",
|
||||
"js-yaml": "5.3.0",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"marked": "18.0.11",
|
||||
"marked": "18.0.10",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -146,18 +143,18 @@
|
||||
"@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.3",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.2",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@html-eslint/eslint-plugin": "0.65.0",
|
||||
"@lokalise/node-api": "16.4.0",
|
||||
"@lokalise/node-api": "16.3.0",
|
||||
"@octokit/auth-oauth-device": "8.0.4",
|
||||
"@octokit/plugin-retry": "8.1.1",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.3",
|
||||
"@rspack/core": "2.2.1",
|
||||
"@rspack/dev-server": "2.2.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.2",
|
||||
"@rspack/core": "2.1.10",
|
||||
"@rspack/dev-server": "2.2.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
@@ -168,19 +165,18 @@
|
||||
"@types/leaflet-draw": "1.0.13",
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
"@types/lodash.merge": "4.6.9",
|
||||
"@types/luxon": "3.7.5",
|
||||
"@types/luxon": "3.7.4",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@typescript/native": "npm:[email protected]",
|
||||
"@versatiles/style": "5.13.1",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist": "4.28.8",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.9.1",
|
||||
"eslint": "10.8.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
@@ -190,7 +186,7 @@
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.4.0",
|
||||
"generate-license-file": "4.2.4",
|
||||
"generate-license-file": "4.2.1",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.11.0",
|
||||
"gulp": "5.0.1",
|
||||
@@ -202,12 +198,12 @@
|
||||
"jszip": "3.10.1",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lightningcss": "1.33.0",
|
||||
"lint-staged": "17.4.1",
|
||||
"lint-staged": "17.3.0",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
"map-stream": "0.0.7",
|
||||
"minify-literals": "2.2.0",
|
||||
"minify-literals": "2.1.0",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.9.6",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
@@ -217,7 +213,7 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.68.0",
|
||||
"typescript-eslint": "8.67.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.11",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
@@ -235,6 +231,6 @@
|
||||
},
|
||||
"packageManager": "[email protected]",
|
||||
"volta": {
|
||||
"node": "24.20.0"
|
||||
"node": "24.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260826.0"
|
||||
version = "20260729.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -27,7 +27,6 @@ const ALLOWED_LICENSES = new Set([
|
||||
"0BSD",
|
||||
"CC0-1.0",
|
||||
"(MIT OR CC0-1.0)",
|
||||
"(MIT OR Apache-2.0)",
|
||||
"(MIT AND Zlib)",
|
||||
"Python-2.0", // argparse - Python Software Foundation License (permissive)
|
||||
"Public Domain",
|
||||
|
||||
@@ -8,4 +8,4 @@ set -eu -o pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
./node_modules/.bin/gulp gen-device-classes
|
||||
./node_modules/.bin/gulp gen-numeric-device-classes
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,70 +0,0 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { timerTimeRemaining } from "../../data/timer";
|
||||
|
||||
/**
|
||||
* Tracks the live remaining time of a timer entity. While the timer is
|
||||
* active, the host is re-rendered every second with an updated
|
||||
* `timeRemaining`, computed from the entity's `finishes_at` attribute.
|
||||
*
|
||||
* The host must call `setStateObj` whenever its timer entity changes.
|
||||
*/
|
||||
export class TimerRemainingTimeController implements ReactiveController {
|
||||
public timeRemaining?: number;
|
||||
|
||||
private _host: ReactiveControllerHost;
|
||||
|
||||
private _stateObj?: HassEntity;
|
||||
|
||||
private _interval?: number;
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
this._host = host;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public setStateObj(stateObj: HassEntity | undefined): void {
|
||||
this._stateObj = stateObj;
|
||||
this._startInterval();
|
||||
}
|
||||
|
||||
public hostConnected(): void {
|
||||
this._startInterval();
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._clearInterval();
|
||||
}
|
||||
|
||||
private _startInterval(): void {
|
||||
this._clearInterval();
|
||||
if (!this._stateObj) {
|
||||
this.timeRemaining = undefined;
|
||||
return;
|
||||
}
|
||||
this._calculateRemaining();
|
||||
|
||||
if (this._stateObj.state === "active") {
|
||||
this._interval = window.setInterval(() => {
|
||||
this._calculateRemaining();
|
||||
this._host.requestUpdate();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private _clearInterval(): void {
|
||||
if (this._interval) {
|
||||
clearInterval(this._interval);
|
||||
this._interval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _calculateRemaining(): void {
|
||||
this.timeRemaining = this._stateObj
|
||||
? timerTimeRemaining(this._stateObj)
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,13 @@
|
||||
import type { Map } from "leaflet";
|
||||
import type { MapBaseLayer } from "../map/base-layer";
|
||||
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../map/base-layer";
|
||||
import type { Map, TileLayer } from "leaflet";
|
||||
|
||||
// Sets up a Leaflet map on the provided DOM element
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
export type LeafletModuleType = typeof import("leaflet");
|
||||
|
||||
export interface LeafletMapSetup {
|
||||
map: Map;
|
||||
leaflet: LeafletModuleType;
|
||||
baseLayer: MapBaseLayer;
|
||||
}
|
||||
|
||||
export const setupLeafletMap = async (
|
||||
mapElement: HTMLElement,
|
||||
initialView?: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zoom?: number;
|
||||
darkMode?: boolean;
|
||||
}
|
||||
): Promise<LeafletMapSetup> => {
|
||||
initialView?: { latitude: number; longitude: number; zoom?: number }
|
||||
): Promise<[Map, LeafletModuleType, TileLayer]> => {
|
||||
if (!mapElement.parentNode) {
|
||||
throw new Error("Cannot setup Leaflet map on disconnected element");
|
||||
}
|
||||
@@ -30,11 +17,7 @@ export const setupLeafletMap = async (
|
||||
|
||||
await import("leaflet.markercluster");
|
||||
|
||||
const map = Leaflet.map(mapElement, {
|
||||
minZoom: MAP_MIN_ZOOM,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
});
|
||||
map.attributionControl.setPrefix("");
|
||||
const map = Leaflet.map(mapElement);
|
||||
const style = document.createElement("link");
|
||||
style.setAttribute("href", "/static/images/leaflet/leaflet.css");
|
||||
style.setAttribute("rel", "stylesheet");
|
||||
@@ -55,13 +38,21 @@ export const setupLeafletMap = async (
|
||||
);
|
||||
}
|
||||
|
||||
// The base layer adds itself: the vector layer only builds its MapLibre map
|
||||
// once it is on the map, and that failing has to fall back to raster.
|
||||
const baseLayer = await createBaseLayer(
|
||||
Leaflet,
|
||||
map,
|
||||
initialView?.darkMode ?? false
|
||||
);
|
||||
const tileLayer = createTileLayer(Leaflet).addTo(map);
|
||||
|
||||
return { map, leaflet: Leaflet, baseLayer };
|
||||
return [map, Leaflet, tileLayer];
|
||||
};
|
||||
|
||||
const createTileLayer = (leaflet: LeafletModuleType): TileLayer =>
|
||||
leaflet.tileLayer(
|
||||
`https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}${
|
||||
leaflet.Browser.retina ? "@2x.png" : ".png"
|
||||
}`,
|
||||
{
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
subdomains: "abcd",
|
||||
minZoom: 0,
|
||||
maxZoom: 20,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -12,13 +12,24 @@ import { getEntityContext } from "./context/get_entity_context";
|
||||
const DEFAULT_SEPARATOR = " ";
|
||||
|
||||
export const DEFAULT_ENTITY_NAME = [
|
||||
{ type: "parent_device" },
|
||||
{ type: "device" },
|
||||
{ type: "entity" },
|
||||
] satisfies EntityNameItem[];
|
||||
|
||||
export const ENTITY_NAME_TYPES = [
|
||||
"entity",
|
||||
"device",
|
||||
"parent_device",
|
||||
"area",
|
||||
"floor",
|
||||
] as const;
|
||||
|
||||
export type EntityNameType = (typeof ENTITY_NAME_TYPES)[number];
|
||||
|
||||
export type EntityNameItem =
|
||||
| {
|
||||
type: "entity" | "device" | "area" | "floor";
|
||||
type: EntityNameType;
|
||||
}
|
||||
| {
|
||||
type: "text";
|
||||
@@ -91,7 +102,7 @@ export const computeEntityNameList = (
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"]
|
||||
): (string | undefined)[] => {
|
||||
const { device, area, floor } = getEntityContext(
|
||||
const { device, parentDevice, area, floor } = getEntityContext(
|
||||
stateObj,
|
||||
entities,
|
||||
devices,
|
||||
@@ -105,6 +116,8 @@ export const computeEntityNameList = (
|
||||
return computeEntityName(stateObj, entities, devices);
|
||||
case "device":
|
||||
return device ? computeDeviceName(device) : undefined;
|
||||
case "parent_device":
|
||||
return parentDevice ? computeDeviceName(parentDevice) : undefined;
|
||||
case "area":
|
||||
return area ? computeAreaName(area) : undefined;
|
||||
case "floor":
|
||||
@@ -136,14 +149,20 @@ export const computeEntityPickerDisplay = (
|
||||
>,
|
||||
stateObj: HassEntity
|
||||
): EntityPickerDisplay => {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(
|
||||
hass.language,
|
||||
@@ -152,7 +171,7 @@ export const computeEntityPickerDisplay = (
|
||||
|
||||
const primary = entityName || deviceName || stateObj.entity_id;
|
||||
const secondary =
|
||||
[areaName, entityName ? deviceName : undefined]
|
||||
[areaName, parentDeviceName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ") || undefined;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getDeviceAreaId } from "./get_device_context";
|
||||
interface EntityContext {
|
||||
entity: EntityRegistryDisplayEntry | null;
|
||||
device: DeviceRegistryEntry | null;
|
||||
parentDevice: DeviceRegistryEntry | null;
|
||||
area: AreaRegistryEntry | null;
|
||||
floor: FloorRegistryEntry | null;
|
||||
}
|
||||
@@ -31,6 +32,7 @@ export const getEntityContext = (
|
||||
return {
|
||||
entity: null,
|
||||
device: null,
|
||||
parentDevice: null,
|
||||
area: null,
|
||||
floor: null,
|
||||
};
|
||||
@@ -65,6 +67,9 @@ export const getEntityEntryContext = (
|
||||
const entity = entities[entry.entity_id];
|
||||
const deviceId = entry?.device_id;
|
||||
const device = deviceId ? devices[deviceId] : undefined;
|
||||
const parentDevice = device?.parent_device_id
|
||||
? devices[device.parent_device_id]
|
||||
: undefined;
|
||||
const areaId =
|
||||
entry?.area_id || (device ? getDeviceAreaId(device, devices) : undefined);
|
||||
const area = areaId ? areas[areaId] : undefined;
|
||||
@@ -74,6 +79,7 @@ export const getEntityEntryContext = (
|
||||
return {
|
||||
entity: entity,
|
||||
device: device || null,
|
||||
parentDevice: parentDevice || null,
|
||||
area: area || null,
|
||||
floor: floor || null,
|
||||
};
|
||||
|
||||
@@ -33,10 +33,7 @@ const normalizeFilterArray = <T>(
|
||||
};
|
||||
|
||||
export const generateEntityFilter = (
|
||||
hass: Pick<
|
||||
HomeAssistant,
|
||||
"states" | "entities" | "devices" | "areas" | "floors"
|
||||
>,
|
||||
hass: HomeAssistant,
|
||||
filter: EntityFilter
|
||||
): EntityFilterFunc => {
|
||||
const domains = filter.domain
|
||||
|
||||
@@ -304,9 +304,6 @@ 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",
|
||||
},
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
import type { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
|
||||
import type { Map as LeafletMap } from "leaflet";
|
||||
import type { setRTLTextPlugin, StyleSpecification } from "maplibre-gl";
|
||||
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
|
||||
|
||||
// Shortbread vector tiles from the OpenStreetMap Foundation. Only their tile
|
||||
// endpoint sends CORS headers, so the style, glyphs and sprites are ours to
|
||||
// serve - see build-scripts/gulp/map-assets.js. The credit comes from the
|
||||
// TileJSON rather than from here, deliberately: it follows whoever serves the
|
||||
// tiles.
|
||||
const VECTOR_STYLES = {
|
||||
light: "/static/map/light.json",
|
||||
dark: "/static/map/dark.json",
|
||||
} as const;
|
||||
|
||||
// Without it Arabic and Hebrew labels render reversed. Loaded by MapLibre's
|
||||
// worker, hence a URL rather than an import.
|
||||
const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
|
||||
|
||||
// Fallback for browsers without WebGL2, which MapLibre needs even for raster,
|
||||
// so it stays a Leaflet tile layer. Still CARTO, and temporarily so: OSM's
|
||||
// raster blocks a browser that sends no Referer, and the only referrer a browser
|
||||
// can send is its origin, which identifies a Nabu Casa installation.
|
||||
const RASTER_TILE_URL = "https://basemaps.cartocdn.com/rastertiles/voyager";
|
||||
const CARTO_ATTRIBUTION =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, ' +
|
||||
'© <a href="https://carto.com/attributions">CARTO</a>';
|
||||
|
||||
// Browsers keep about 16 live WebGL contexts and drop the oldest, so a dashboard
|
||||
// full of map cards loses its first ones for good - nothing frees a slot for
|
||||
// MapLibre to reclaim. A transient loss does get restored, hence the grace.
|
||||
const CONTEXT_RESTORE_GRACE = 2000;
|
||||
|
||||
// On the map, not the layer: only Leaflet tile layers report their own limits,
|
||||
// and marker clustering throws without a maximum. The floor is 1 because at
|
||||
// Leaflet zoom 0 the adapter drives MapLibre to -1, outside its range.
|
||||
export const MAP_MIN_ZOOM = 1;
|
||||
export const MAP_MAX_ZOOM = 20;
|
||||
|
||||
export interface MapBaseLayer {
|
||||
// A no-op for raster, which has no dark variant and is inverted in CSS.
|
||||
setDarkMode: (darkMode: boolean) => void;
|
||||
}
|
||||
|
||||
let webGL2Supported: boolean | undefined;
|
||||
|
||||
// Rules out iOS below 15, older Android tablets, and blocklisted drivers.
|
||||
const supportsWebGL2 = (): boolean => {
|
||||
if (webGL2Supported === undefined) {
|
||||
try {
|
||||
const context = document.createElement("canvas").getContext("webgl2");
|
||||
webGL2Supported = Boolean(context);
|
||||
// Contexts are scarce; the probe must not keep one.
|
||||
context?.getExtension("WEBGL_lose_context")?.loseContext();
|
||||
} catch {
|
||||
webGL2Supported = false;
|
||||
}
|
||||
}
|
||||
return webGL2Supported;
|
||||
};
|
||||
|
||||
// Asset URLs are stored origin relative so they follow the instance's host, but
|
||||
// MapLibre rejects a relative sprite URL. The glyph URL is left alone: URL
|
||||
// encoding would mangle its {fontstack} and {range} placeholders.
|
||||
const loadStyle = async (url: string): Promise<StyleSpecification> => {
|
||||
const style: StyleSpecification = await (await fetch(url)).json();
|
||||
if (typeof style.sprite === "string") {
|
||||
style.sprite = new URL(style.sprite, location.href).href;
|
||||
} else if (Array.isArray(style.sprite)) {
|
||||
style.sprite = style.sprite.map((sprite) => ({
|
||||
...sprite,
|
||||
url: new URL(sprite.url, location.href).href,
|
||||
}));
|
||||
}
|
||||
return style;
|
||||
};
|
||||
|
||||
// Global to MapLibre, and it throws when set twice.
|
||||
let rtlTextPluginRequested = false;
|
||||
const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
|
||||
if (rtlTextPluginRequested) {
|
||||
return;
|
||||
}
|
||||
rtlTextPluginRequested = true;
|
||||
setPlugin(new URL(RTL_TEXT_PLUGIN_URL, location.href).href, true).catch(
|
||||
() => {
|
||||
// RTL labels stay reversed; everything else still renders.
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const createVectorLayer = async (
|
||||
createLayer: typeof maplibreGL,
|
||||
leaflet: LeafletModuleType,
|
||||
map: LeafletMap,
|
||||
darkMode: boolean
|
||||
): Promise<MapBaseLayer | undefined> => {
|
||||
let layer: ReturnType<typeof maplibreGL> | undefined;
|
||||
|
||||
try {
|
||||
layer = createLayer({
|
||||
style: await loadStyle(VECTOR_STYLES[darkMode ? "dark" : "light"]),
|
||||
// Draws CJK, kana and hangul with a device font, which is why we ship a
|
||||
// tenth of the glyph set. No referrer is set: the endpoint serves without
|
||||
// one, and the only one a browser can send identifies the installation.
|
||||
localIdeographFontFamily: "sans-serif",
|
||||
});
|
||||
// The plugin builds the MapLibre map in `onAdd`, so a refused context, a
|
||||
// blocked worker or a rejected blob URL throws here. Keep it inside the
|
||||
// guard or those lose the raster fallback.
|
||||
layer.addTo(map);
|
||||
} catch {
|
||||
if (layer) {
|
||||
try {
|
||||
layer.remove();
|
||||
} catch {
|
||||
// May never have finished being added.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Tracked apart because a failed request must roll back to what is displayed,
|
||||
// not to the opposite of what it asked for - with several in flight those are
|
||||
// different, and guessing wrong makes the next toggle a permanent no-op.
|
||||
let appliedDarkMode = darkMode;
|
||||
let requestedDarkMode = darkMode;
|
||||
// Styles are fetched, so only the newest request may touch the map.
|
||||
let latestRequest = 0;
|
||||
let vector = true;
|
||||
|
||||
const glMap = layer.getMaplibreMap();
|
||||
let fallbackTimeout: number | undefined;
|
||||
let contextLost = false;
|
||||
|
||||
// Declared first, but only ever called once all three exist.
|
||||
const handleVisibilityChange = () => {
|
||||
if (contextLost) {
|
||||
scheduleSwap();
|
||||
}
|
||||
};
|
||||
|
||||
const swapToRaster = () => {
|
||||
vector = false;
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
try {
|
||||
layer.remove();
|
||||
} catch {
|
||||
// Nothing left to detach.
|
||||
}
|
||||
createRasterLayer(leaflet, map);
|
||||
};
|
||||
|
||||
const scheduleSwap = () => {
|
||||
clearTimeout(fallbackTimeout);
|
||||
// Backgrounding also drops the context, and there it comes back on return.
|
||||
// Running the clock then would make switching apps enough to lose vector.
|
||||
if (!vector || document.hidden) {
|
||||
return;
|
||||
}
|
||||
fallbackTimeout = window.setTimeout(swapToRaster, CONTEXT_RESTORE_GRACE);
|
||||
};
|
||||
|
||||
glMap.on("webglcontextlost", () => {
|
||||
contextLost = true;
|
||||
scheduleSwap();
|
||||
});
|
||||
glMap.on("webglcontextrestored", () => {
|
||||
contextLost = false;
|
||||
clearTimeout(fallbackTimeout);
|
||||
});
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
map.on("unload", () => {
|
||||
// Otherwise the timer revives a map that is already gone.
|
||||
clearTimeout(fallbackTimeout);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
});
|
||||
|
||||
return {
|
||||
setDarkMode: (newDarkMode: boolean) => {
|
||||
if (!vector || newDarkMode === requestedDarkMode) {
|
||||
return;
|
||||
}
|
||||
requestedDarkMode = newDarkMode;
|
||||
const request = ++latestRequest;
|
||||
|
||||
loadStyle(VECTOR_STYLES[newDarkMode ? "dark" : "light"])
|
||||
.then((style) => {
|
||||
if (request === latestRequest) {
|
||||
appliedDarkMode = newDarkMode;
|
||||
layer.getMaplibreMap()?.setStyle(style);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (request === latestRequest) {
|
||||
requestedDarkMode = appliedDarkMode;
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createRasterLayer = (
|
||||
leaflet: LeafletModuleType,
|
||||
map: LeafletMap
|
||||
): MapBaseLayer => {
|
||||
leaflet
|
||||
.tileLayer(
|
||||
// These are the old retina tablets, and CARTO serves @2x - sharp tiles at
|
||||
// the same request count, where `detectRetina` would fetch a zoom deeper
|
||||
// at four times as many.
|
||||
`${RASTER_TILE_URL}/{z}/{x}/{y}${leaflet.Browser.retina ? "@2x" : ""}.png`,
|
||||
{
|
||||
attribution: CARTO_ATTRIBUTION,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
}
|
||||
)
|
||||
.addTo(map);
|
||||
|
||||
return { setDarkMode: () => undefined };
|
||||
};
|
||||
|
||||
export const createBaseLayer = async (
|
||||
leaflet: LeafletModuleType,
|
||||
map: LeafletMap,
|
||||
darkMode: boolean
|
||||
): Promise<MapBaseLayer> => {
|
||||
if (supportsWebGL2()) {
|
||||
let vectorLayer: MapBaseLayer | undefined;
|
||||
try {
|
||||
const [{ maplibreGL: createLayer }, maplibre] = await Promise.all([
|
||||
import("@maplibre/maplibre-gl-leaflet"),
|
||||
import("maplibre-gl"),
|
||||
]);
|
||||
ensureRTLTextPlugin(maplibre.setRTLTextPlugin);
|
||||
vectorLayer = await createVectorLayer(
|
||||
createLayer,
|
||||
leaflet,
|
||||
map,
|
||||
darkMode
|
||||
);
|
||||
} catch {
|
||||
// No chunk, no vector map - but still a map.
|
||||
}
|
||||
if (vectorLayer) {
|
||||
return vectorLayer;
|
||||
}
|
||||
}
|
||||
return createRasterLayer(leaflet, map);
|
||||
};
|
||||
+2
-85
@@ -80,67 +80,6 @@ const ensureDialogsClosed = async (timestamp: number): Promise<boolean> => {
|
||||
return ensureDialogsClosed(timestamp);
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a page with unsaved changes (e.g. the automation editor) veto
|
||||
* navigation. `isDirty` is read live at navigation time; `prompt` resolves
|
||||
* true when navigation may proceed.
|
||||
*/
|
||||
export interface UnsavedChangesGuard {
|
||||
isDirty(): boolean;
|
||||
prompt(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const unsavedChangesGuards = new Set<UnsavedChangesGuard>();
|
||||
|
||||
export const registerUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
|
||||
unsavedChangesGuards.add(guard);
|
||||
};
|
||||
|
||||
export const unregisterUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
|
||||
unsavedChangesGuards.delete(guard);
|
||||
};
|
||||
|
||||
let pendingUnsavedPrompt: Promise<boolean> | undefined;
|
||||
|
||||
/**
|
||||
* Counts navigations that changed the history entry, so a navigation held up
|
||||
* by an unsaved-changes prompt can tell whether a newer one has moved the app
|
||||
* on in the meantime.
|
||||
*/
|
||||
let committedNavigations = 0;
|
||||
|
||||
/**
|
||||
* Asks each dirty guard whether navigation may proceed. Returns true when
|
||||
* nothing is dirty or every prompt was confirmed. Concurrent navigations
|
||||
* share one pending prompt instead of stacking dialogs; the dirty check runs
|
||||
* before joining it, so a navigation triggered from inside a prompt (e.g. by
|
||||
* its save action) cannot deadlock on its own promise.
|
||||
*/
|
||||
const ensureUnsavedChangesConfirmed = (): Promise<boolean> => {
|
||||
const dirtyGuards = [...unsavedChangesGuards].filter((guard) =>
|
||||
guard.isDirty()
|
||||
);
|
||||
if (!dirtyGuards.length) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
if (!pendingUnsavedPrompt) {
|
||||
pendingUnsavedPrompt = (async () => {
|
||||
try {
|
||||
for (const guard of dirtyGuards) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (!(await guard.prompt())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
pendingUnsavedPrompt = undefined;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return pendingUnsavedPrompt;
|
||||
};
|
||||
|
||||
const buildHistoryState = (
|
||||
data: Record<string, unknown> | undefined,
|
||||
from?: string
|
||||
@@ -152,7 +91,7 @@ const buildHistoryState = (
|
||||
return { ...state, from };
|
||||
};
|
||||
|
||||
const performNavigation = async (path: string, options?: NavigateOptions) => {
|
||||
export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
if (!canProceed) {
|
||||
return false;
|
||||
@@ -188,28 +127,9 @@ const performNavigation = async (path: string, options?: NavigateOptions) => {
|
||||
fireEvent(mainWindow, "location-changed", {
|
||||
replace,
|
||||
});
|
||||
committedNavigations += 1;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
// Only guard actual departures: navigating to the current path keeps the
|
||||
// page, and any unsaved state on it, mounted.
|
||||
if (path !== currentPath()) {
|
||||
const navigationsBeforePrompt = committedNavigations;
|
||||
if (!(await ensureUnsavedChangesConfirmed())) {
|
||||
return false;
|
||||
}
|
||||
if (committedNavigations !== navigationsBeforePrompt) {
|
||||
// Another navigation landed while the prompt was waiting for an answer,
|
||||
// so this destination is stale. Dropping it keeps a late answer from
|
||||
// pulling the user back off the page they are on now.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return performNavigation(path, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the previous history entry is a page this app navigated away from.
|
||||
* `history.length` cannot answer this: a login redirect goes through
|
||||
@@ -222,9 +142,6 @@ export const canGoBack = (): boolean =>
|
||||
/**
|
||||
* Navigate back to the page we came from, falling back to a path when the
|
||||
* previous entry is not ours (deep link, login redirect, fresh tab).
|
||||
* Deliberately not guarded against unsaved changes: pages with such a guard
|
||||
* confirm in their own back handlers, and delete flows leave through here
|
||||
* after the edited item is already gone.
|
||||
*/
|
||||
export const goBack = async (fallbackPath?: string): Promise<void> => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
@@ -239,5 +156,5 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
await performNavigation(fallbackPath || "/", { replace: true });
|
||||
await navigate(fallbackPath || "/", { replace: true });
|
||||
};
|
||||
|
||||
@@ -31,8 +31,6 @@ export type FormatEntityAttributeNameFunc = (
|
||||
attribute: string
|
||||
) => string;
|
||||
|
||||
export type EntityNameType = "entity" | "device" | "area" | "floor";
|
||||
|
||||
export type FormatEntityNameFunc = (
|
||||
stateObj: HassEntity,
|
||||
name: EntityNameItem | EntityNameItem[],
|
||||
|
||||
@@ -10,15 +10,18 @@ const VIEW_PARAM = "more-info-view";
|
||||
export interface MoreInfoUrlData {
|
||||
entityId?: string;
|
||||
view?: MoreInfoView;
|
||||
hash: URLSearchParams;
|
||||
}
|
||||
|
||||
export interface CreateMoreInfoUrlData {
|
||||
entityId: string;
|
||||
view: MoreInfoView;
|
||||
hash?: URLSearchParams;
|
||||
}
|
||||
|
||||
export const decodeMoreInfoUrl = (
|
||||
search: SearchParamsSource
|
||||
search: SearchParamsSource,
|
||||
hash = ""
|
||||
): MoreInfoUrlData => {
|
||||
const params =
|
||||
typeof search === "string"
|
||||
@@ -32,6 +35,9 @@ export const decodeMoreInfoUrl = (
|
||||
return {
|
||||
entityId,
|
||||
view: isMoreInfoView(view) ? view : undefined,
|
||||
hash: new URLSearchParams(
|
||||
__DEMO__ ? "" : hash.startsWith("#") ? hash.substring(1) : hash
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -42,6 +48,9 @@ export const createMoreInfoUrl = (
|
||||
const url = new URL(base, window.location.origin);
|
||||
url.searchParams.set(ENTITY_ID_PARAM, data.entityId);
|
||||
url.searchParams.set(VIEW_PARAM, data.view);
|
||||
if (!__DEMO__ && data.hash !== undefined) {
|
||||
url.hash = data.hash.toString();
|
||||
}
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
};
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import deepFreeze from "deep-freeze";
|
||||
|
||||
const inFlightRequests = new WeakMap<object, Map<string, Promise<unknown>>>();
|
||||
|
||||
export const shareInFlightRequest = <T>(
|
||||
owner: object,
|
||||
key: string,
|
||||
fetcher: () => Promise<T>
|
||||
): Promise<T> => {
|
||||
let requests = inFlightRequests.get(owner);
|
||||
if (!requests) {
|
||||
requests = new Map();
|
||||
inFlightRequests.set(owner, requests);
|
||||
}
|
||||
|
||||
const ownerRequests = requests;
|
||||
const existing = ownerRequests.get(key);
|
||||
if (existing) {
|
||||
return existing as Promise<T>;
|
||||
}
|
||||
|
||||
const request = fetcher()
|
||||
.then((result) => deepFreeze(result) as T)
|
||||
.finally(() => {
|
||||
if (ownerRequests.get(key) !== request) {
|
||||
return;
|
||||
}
|
||||
|
||||
ownerRequests.delete(key);
|
||||
if (ownerRequests.size === 0) {
|
||||
inFlightRequests.delete(owner);
|
||||
}
|
||||
});
|
||||
|
||||
ownerRequests.set(key, request);
|
||||
return request;
|
||||
};
|
||||
@@ -55,20 +55,17 @@ export const timeCachePromiseFunc = async <T, H = HomeAssistant>(
|
||||
}
|
||||
|
||||
const resultPromise = func(hass, ...args);
|
||||
const cachePromise = resultPromise.then((result) => ({
|
||||
result,
|
||||
cacheKey: generateCacheKey?.(hass, result),
|
||||
}));
|
||||
anyHass[cacheKey] = cachePromise;
|
||||
anyHass[cacheKey] = resultPromise;
|
||||
|
||||
cachePromise.then(
|
||||
resultPromise.then(
|
||||
// When successful, set timer to clear cache
|
||||
(result) => {
|
||||
anyHass[cacheKey] = result;
|
||||
anyHass[cacheKey] = {
|
||||
result,
|
||||
cacheKey: generateCacheKey?.(hass, result),
|
||||
};
|
||||
setTimeout(() => {
|
||||
if (anyHass[cacheKey] === result) {
|
||||
anyHass[cacheKey] = undefined;
|
||||
}
|
||||
anyHass[cacheKey] = undefined;
|
||||
}, cacheTime);
|
||||
},
|
||||
// On failure, clear cache right away
|
||||
|
||||
@@ -27,57 +27,26 @@ const SONIFICATION_LANGUAGES = new Set(["de", "en", "es", "fr", "hmn", "it"]);
|
||||
// lead nowhere.
|
||||
const MIN_NAVIGABLE_POINTS = 2;
|
||||
|
||||
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.
|
||||
// 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 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;
|
||||
const values = itemValues(raw);
|
||||
if (values) {
|
||||
y = valueFirst ? values[0] : values.length > 1 ? values[1] : values[0];
|
||||
if (Array.isArray(raw)) {
|
||||
y = raw.length > 1 ? raw[1] : raw[0];
|
||||
} else if (raw && typeof raw === "object") {
|
||||
y = (raw as { value?: unknown }).value;
|
||||
const { value } = raw as { value?: unknown };
|
||||
y = Array.isArray(value)
|
||||
? value.length > 1
|
||||
? value[1]
|
||||
: value[0]
|
||||
: value;
|
||||
}
|
||||
if (typeof y === "number" && !Number.isNaN(y)) {
|
||||
found += 1;
|
||||
@@ -126,10 +95,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -198,47 +163,6 @@ 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
|
||||
@@ -255,8 +179,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
|
||||
@@ -272,25 +196,16 @@ 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. 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.
|
||||
// axis to name when there is no category axis, so label both explicitly.
|
||||
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:
|
||||
(isHorizontal ? yAxis?.name : xAxis?.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
|
||||
@@ -298,9 +213,7 @@ export const sonifyChart = async (
|
||||
: undefined,
|
||||
};
|
||||
const y = {
|
||||
label:
|
||||
(isHorizontal ? xAxis?.name : yAxis?.name) ||
|
||||
localize("ui.components.history_charts.value"),
|
||||
label: yAxis?.name || localize("ui.components.history_charts.value"),
|
||||
};
|
||||
|
||||
let connection: ReturnType<typeof connect>;
|
||||
|
||||
@@ -3,27 +3,6 @@ import type { TooltipPositionCallback } from "echarts/types/dist/shared";
|
||||
export const TOOLTIP_GAP_PX = 12;
|
||||
export const TOOLTIP_TOP_OFFSET_PX = 10;
|
||||
|
||||
const offsetFromCursor = (
|
||||
cursorX: number,
|
||||
dom: unknown,
|
||||
viewW: number,
|
||||
tipW: number
|
||||
) => {
|
||||
const rtl =
|
||||
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
|
||||
|
||||
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
|
||||
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
|
||||
|
||||
let x = rtl ? leftOfCursor : rightOfCursor;
|
||||
const overflowsRight = x + tipW > viewW;
|
||||
const overflowsLeft = x < 0;
|
||||
if (overflowsRight || overflowsLeft) {
|
||||
x = rtl ? rightOfCursor : leftOfCursor;
|
||||
}
|
||||
return Math.max(0, Math.min(x, viewW - tipW));
|
||||
};
|
||||
|
||||
/**
|
||||
* Pins the tooltip near the top of the chart and offsets it horizontally
|
||||
* from the cursor so it never covers the data point being inspected.
|
||||
@@ -41,29 +20,21 @@ export const sideTooltipPosition: TooltipPositionCallback = (
|
||||
const [viewW, viewH] = size.viewSize;
|
||||
const [tipW, tipH] = size.contentSize;
|
||||
|
||||
const x = offsetFromCursor(cursorX, dom, viewW, tipW);
|
||||
const rtl =
|
||||
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
|
||||
|
||||
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
|
||||
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
|
||||
|
||||
let x = rtl ? leftOfCursor : rightOfCursor;
|
||||
const overflowsRight = x + tipW > viewW;
|
||||
const overflowsLeft = x < 0;
|
||||
if (overflowsRight || overflowsLeft) {
|
||||
x = rtl ? rightOfCursor : leftOfCursor;
|
||||
}
|
||||
x = Math.max(0, Math.min(x, viewW - tipW));
|
||||
|
||||
const y = Math.max(0, Math.min(TOOLTIP_TOP_OFFSET_PX, viewH - tipH));
|
||||
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
/**
|
||||
* Offsets the tooltip horizontally from the cursor and keeps it level with it.
|
||||
* For item-trigger tooltips where the cursor's row is what the tooltip shows.
|
||||
*/
|
||||
export const itemTooltipPosition: TooltipPositionCallback = (
|
||||
point,
|
||||
_params,
|
||||
dom,
|
||||
_rect,
|
||||
size
|
||||
) => {
|
||||
const [cursorX, cursorY] = point;
|
||||
const [viewW, viewH] = size.viewSize;
|
||||
const [tipW, tipH] = size.contentSize;
|
||||
|
||||
const x = offsetFromCursor(cursorX, dom, viewW, tipW);
|
||||
const y = Math.max(0, Math.min(cursorY - tipH / 2, viewH - tipH));
|
||||
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
@@ -10,16 +10,12 @@ 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;
|
||||
@@ -53,25 +49,6 @@ 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],
|
||||
>(
|
||||
@@ -105,10 +82,7 @@ export function downSampleLineData<
|
||||
const pointData = getPointData(point);
|
||||
if (!Array.isArray(pointData)) continue;
|
||||
const x = Number(pointData[0]);
|
||||
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);
|
||||
const y = Number(pointData[1]);
|
||||
if (isNaN(x) || isNaN(y)) continue;
|
||||
|
||||
const frameIndex = Math.floor(x / step);
|
||||
@@ -146,34 +120,21 @@ export function downSampleLineData<
|
||||
const pointData = getPointData(point);
|
||||
if (!Array.isArray(pointData)) continue;
|
||||
const x = Number(pointData[0]);
|
||||
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 y = Number(pointData[1]);
|
||||
if (isNaN(x) || isNaN(y)) continue;
|
||||
|
||||
const frameIndex = Math.floor(x / step);
|
||||
const frame = frames.get(frameIndex);
|
||||
if (!frame) {
|
||||
frames.set(frameIndex, newFrame(point, x, y, undefined));
|
||||
} else if (frame.hasValue) {
|
||||
frames.set(frameIndex, {
|
||||
minPoint: point,
|
||||
minX: x,
|
||||
minY: y,
|
||||
maxPoint: point,
|
||||
maxX: x,
|
||||
maxY: y,
|
||||
});
|
||||
} else {
|
||||
// Match the original strict-less / strict-greater comparisons so the
|
||||
// first occurrence wins on ties.
|
||||
if (y < frame.minY) {
|
||||
@@ -186,40 +147,18 @@ 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()) {
|
||||
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);
|
||||
}
|
||||
// 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.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);
|
||||
}
|
||||
result.push(frame.minPoint as T);
|
||||
if (frame.minX < frame.maxX) {
|
||||
result.push(frame.maxPoint as T);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,11 +117,6 @@ 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;
|
||||
|
||||
@@ -588,7 +583,6 @@ 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.
|
||||
|
||||
@@ -328,7 +328,6 @@ export class StateHistoryChartLine extends LitElement {
|
||||
...createYAxisPrecisionBounds({
|
||||
min: this._clampYAxis(minYAxis),
|
||||
max: this._clampYAxis(maxYAxis),
|
||||
unit: this.unit,
|
||||
onFractionDigits: (digits) => {
|
||||
if (digits !== this._yAxisFractionDigits) {
|
||||
this._yAxisFractionDigits = digits;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -12,7 +13,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { TimelineEntity } from "../../data/history";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import { itemTooltipPosition } from "./chart-tooltip-position";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import { computeTimelineColor } from "./timeline-color";
|
||||
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
|
||||
@@ -23,6 +24,7 @@ import { measureTextWidth } from "../../util/text";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
|
||||
const ROW_HEIGHT = 30;
|
||||
const ROW_HEIGHT_INSIDE_LABELS = 64;
|
||||
const GRID_BOTTOM = 30;
|
||||
|
||||
@customElement("state-history-chart-timeline")
|
||||
@@ -41,6 +43,10 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
|
||||
|
||||
/** Draw each row's name above its bar instead of in a label column. */
|
||||
@property({ attribute: "inside-labels", type: Boolean })
|
||||
public insideLabels = false;
|
||||
|
||||
@property({ attribute: "click-for-more-info", type: Boolean })
|
||||
public clickForMoreInfo = true;
|
||||
|
||||
@@ -63,6 +69,13 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
@state() private _yWidth = 0;
|
||||
|
||||
private _width = 0;
|
||||
|
||||
private _resize = new ResizeController(this, {
|
||||
skipInitial: true,
|
||||
callback: (entries) => entries[0]?.contentRect.width,
|
||||
});
|
||||
|
||||
private _chartTime: Date = new Date();
|
||||
|
||||
protected render() {
|
||||
@@ -70,7 +83,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
<ha-chart-base
|
||||
.hass=${this.hass}
|
||||
.options=${this._chartOptions}
|
||||
.height=${`${this.data.length * ROW_HEIGHT + GRID_BOTTOM}px`}
|
||||
.height=${`${this.data.length * (this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) + GRID_BOTTOM}px`}
|
||||
.data=${this._chartData as HaECSeries}
|
||||
small-controls
|
||||
@chart-click=${this._handleChartClick}
|
||||
@@ -180,13 +193,19 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
this._generateData();
|
||||
}
|
||||
|
||||
const width = this.insideLabels ? Math.round(this._resize.value ?? 0) : 0;
|
||||
const widthChanged = width !== this._width;
|
||||
this._width = width;
|
||||
|
||||
if (
|
||||
!this.hasUpdated ||
|
||||
changedProps.has("startTime") ||
|
||||
changedProps.has("endTime") ||
|
||||
changedProps.has("showNames") ||
|
||||
changedProps.has("insideLabels") ||
|
||||
changedProps.has("paddingYAxis") ||
|
||||
changedProps.has("_yWidth")
|
||||
changedProps.has("_yWidth") ||
|
||||
widthChanged
|
||||
) {
|
||||
this._createOptions();
|
||||
}
|
||||
@@ -196,14 +215,22 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
const narrow = this.narrow;
|
||||
const showNames = this.chunked || this.showNames;
|
||||
const maxInternalLabelWidth = narrow ? 105 : 185;
|
||||
const labelWidth = showNames
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const insideLabels = this.insideLabels;
|
||||
const labelWidth =
|
||||
showNames && !insideLabels
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const labelMargin = 5;
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
// Keeps the plot aligned with the line charts sharing the y-axis padding.
|
||||
const plotPadding = insideLabels ? this.paddingYAxis : labelWidth;
|
||||
// A zero width hides the labels instead of truncating them.
|
||||
const insideLabelWidth = this._width
|
||||
? Math.max(0, this._width - plotPadding - labelMargin)
|
||||
: undefined;
|
||||
this._chartOptions = {
|
||||
xAxis: {
|
||||
type: "time",
|
||||
@@ -227,41 +254,56 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
show: showNames,
|
||||
width: labelWidth,
|
||||
overflow: "truncate",
|
||||
margin: labelMargin,
|
||||
formatter: (id: string) => {
|
||||
const label = this._chartData.find((d) => d.id === id)
|
||||
?.name as string;
|
||||
const width = label
|
||||
? Math.min(
|
||||
measureTextWidth(label, 12) + labelMargin,
|
||||
maxInternalLabelWidth
|
||||
)
|
||||
: 0;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
axisLabel: insideLabels
|
||||
? {
|
||||
show: showNames,
|
||||
inside: true,
|
||||
margin: 0,
|
||||
padding: [0, rtl ? 2 : 0, 14, rtl ? 0 : 2],
|
||||
align: rtl ? "right" : "left",
|
||||
verticalAlign: "bottom",
|
||||
width: insideLabelWidth,
|
||||
overflow: "truncate",
|
||||
formatter: (id: string) =>
|
||||
(this._chartData.find((d) => d.id === id)?.name as string) ??
|
||||
"",
|
||||
hideOverlap: true,
|
||||
}
|
||||
return label;
|
||||
},
|
||||
hideOverlap: true,
|
||||
},
|
||||
: {
|
||||
show: showNames,
|
||||
width: labelWidth,
|
||||
overflow: "truncate",
|
||||
margin: labelMargin,
|
||||
formatter: (id: string) => {
|
||||
const label = this._chartData.find((d) => d.id === id)
|
||||
?.name as string;
|
||||
const width = label
|
||||
? Math.min(
|
||||
measureTextWidth(label, 12) + labelMargin,
|
||||
maxInternalLabelWidth
|
||||
)
|
||||
: 0;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
return label;
|
||||
},
|
||||
hideOverlap: true,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: 10,
|
||||
top: insideLabels ? 20 : 10,
|
||||
bottom: GRID_BOTTOM,
|
||||
left: rtl ? 1 : labelWidth,
|
||||
right: rtl ? labelWidth : 1,
|
||||
left: rtl ? 1 : plotPadding,
|
||||
right: rtl ? plotPadding : 1,
|
||||
},
|
||||
tooltip: {
|
||||
renderMode: "html",
|
||||
position: itemTooltipPosition,
|
||||
position: sideTooltipPosition,
|
||||
confine: true,
|
||||
formatter: this._renderTooltip,
|
||||
},
|
||||
@@ -401,6 +443,10 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
ha-chart-base {
|
||||
--chart-max-height: none;
|
||||
}
|
||||
|
||||
@@ -79,6 +79,10 @@ export class StateHistoryCharts extends LitElement {
|
||||
|
||||
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
|
||||
|
||||
/** Draw timeline row names above their bar instead of in a label column. */
|
||||
@property({ attribute: "inside-labels", type: Boolean, reflect: true })
|
||||
public insideLabels = false;
|
||||
|
||||
@property({ attribute: "click-for-more-info", type: Boolean })
|
||||
public clickForMoreInfo = true;
|
||||
|
||||
@@ -227,6 +231,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
.startTime=${this._computedStartTime}
|
||||
.endTime=${this._computedEndTime}
|
||||
.showNames=${this.showNames}
|
||||
.insideLabels=${this.insideLabels}
|
||||
.names=${this.names}
|
||||
.narrow=${this.narrow}
|
||||
.chunked=${this.virtualize}
|
||||
@@ -424,6 +429,12 @@ export class StateHistoryCharts extends LitElement {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
/* Names inside the plot sit close to the chart above them, so the groups
|
||||
need more room between them to stay apart. */
|
||||
:host([inside-labels]) .entry-container.timeline:not(:first-child) {
|
||||
margin-top: var(--ha-space-8);
|
||||
}
|
||||
|
||||
.entry-container:hover {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -446,7 +446,6 @@ export class StatisticsChart extends LitElement {
|
||||
...createYAxisPrecisionBounds({
|
||||
min: this._clampYAxis(minYAxis),
|
||||
max: this._clampYAxis(maxYAxis),
|
||||
unit: this.unit,
|
||||
// Bar charts stay anchored at 0, so precision must reflect the
|
||||
// 0-based range that is actually rendered.
|
||||
includeZero: !yAxisScale,
|
||||
|
||||
@@ -1,32 +1,13 @@
|
||||
import { intervalScaleEnsureValidExtent } from "echarts/lib/scale/helper";
|
||||
import { getPrecision, nice, round } from "echarts/lib/util/number";
|
||||
|
||||
// A range smaller than this fraction of the axis magnitude is floating-point
|
||||
// noise (e.g. from summed statistics), not real precision.
|
||||
const NEGLIGIBLE_RANGE_RATIO = 1e-10;
|
||||
|
||||
// Intervals the axis aims for. Passed to ECharts rather than assumed, so the
|
||||
// precision derived here cannot drift from the ticks it renders.
|
||||
const SPLIT_NUMBER = 5;
|
||||
|
||||
// How thin a gap between the data and the plot edge counts as no gap at all,
|
||||
// as a fraction of the data span. ECharts floors the axis minimum and ceils the
|
||||
// maximum to a tick multiple, which usually leaves headroom, but quantized
|
||||
// states often land exactly on a tick and get none — collapsing area-filled
|
||||
// series, which are drawn from their value down to the axis minimum. Widening
|
||||
// the extent by this much before that rounding bumps any axis with less
|
||||
// headroom out to a full tick, and leaves the rest where they are.
|
||||
const GAP_FRACTION_OF_SPAN = 0.02;
|
||||
|
||||
// A percentage has a real ceiling the way zero is a real floor, so the gap must
|
||||
// not push the axis past it. Not every `%` sensor is bounded — power factor is
|
||||
// signed and can read over 100 — so this only applies while the data stays under.
|
||||
const PERCENT_MAX = 100;
|
||||
|
||||
// Derive the number of decimal digits to use for Y-axis labels from the
|
||||
// observed data range, by asking ECharts for the same tick interval it will
|
||||
// render. This matches the precision it actually draws, so labels are neither
|
||||
// truncated to identical values nor padded with extra zeros.
|
||||
// observed data range. We mirror how ECharts sizes its ticks: it splits the
|
||||
// range into ~5 intervals (its default `splitNumber`) and rounds that raw
|
||||
// interval to a "nice" 1/2/3/5×10ⁿ value, then reports the decimals that nice
|
||||
// interval needs. This matches the precision ECharts actually renders, so
|
||||
// labels are neither truncated to identical values nor padded with extra zeros.
|
||||
export function computeYAxisFractionDigits(
|
||||
min: number,
|
||||
max: number,
|
||||
@@ -41,7 +22,13 @@ export function computeYAxisFractionDigits(
|
||||
// with a tail of zeros (e.g. "0.20000000000000"), so treat it as flat.
|
||||
const magnitude = Math.max(Math.abs(lo), Math.abs(hi));
|
||||
if (range <= magnitude * NEGLIGIBLE_RANGE_RATIO) return 1;
|
||||
return getPrecision(nice(range / SPLIT_NUMBER, true));
|
||||
const rawInterval = range / 5;
|
||||
const exponent = Math.floor(Math.log10(rawInterval));
|
||||
const mantissa = rawInterval / 10 ** exponent; // in [1, 10)
|
||||
// Rounding the mantissa to a nice value only ever carries to the next power
|
||||
// of ten (mantissa ≥ 7 → 10), which needs one fewer decimal.
|
||||
const niceExponent = mantissa >= 7 ? exponent + 1 : exponent;
|
||||
return Math.max(0, -niceExponent);
|
||||
}
|
||||
|
||||
interface YAxisExtentValues {
|
||||
@@ -57,116 +44,34 @@ const resolveYAxisBound = (
|
||||
values: YAxisExtentValues
|
||||
): number | undefined => (typeof bound === "function" ? bound(values) : bound);
|
||||
|
||||
// A constant series has no span for the gap to be a fraction of, so ECharts
|
||||
// falls back to `Math.abs(min)` when sizing it. That makes the extent unequal,
|
||||
// which in turn stops `intervalScaleEnsureValidExtent` from applying the ±|v|/2
|
||||
// expansion a flat series relies on for its window. Run that expansion here and
|
||||
// return fixed bounds, which also suppresses the gap.
|
||||
const flatSeriesExtent = (value: number, fixed: [boolean, boolean]) => {
|
||||
const [lo, hi] = intervalScaleEnsureValidExtent([value, value], fixed);
|
||||
const interval = nice((hi - lo) / SPLIT_NUMBER, true);
|
||||
const precision = getPrecision(interval);
|
||||
return {
|
||||
min: round(Math.floor(lo / interval) * interval, precision),
|
||||
max: round(Math.ceil(hi / interval) * interval, precision),
|
||||
};
|
||||
};
|
||||
|
||||
// Build the `yAxis` options that keep tick-label precision and the plot-edge gap
|
||||
// in agreement with the extent ECharts renders. It re-invokes the `min`/`max`
|
||||
// callbacks with the extent of the visible (zoom-filtered) data on every
|
||||
// dataZoom, and always before the label formatter runs, so the fraction digits
|
||||
// recomputed here track the zoomed range. A callback returns `undefined`
|
||||
// wherever auto-scaling should stand, and a number only where the axis has to be
|
||||
// pinned: an explicit bound, the zero anchor, or a constant series.
|
||||
// Wrap the Y-axis `min`/`max` options in callbacks so the tick-label precision
|
||||
// tracks the currently visible axis extent. ECharts re-invokes these callbacks
|
||||
// with the extent of the visible (zoom-filtered) data on every dataZoom, and
|
||||
// always before the label formatter runs, so recomputing the fraction digits
|
||||
// here keeps zoomed-in labels distinct. The callbacks return the original
|
||||
// bounds unchanged, so auto-scaling still applies when a bound is not set.
|
||||
export function createYAxisPrecisionBounds(options: {
|
||||
min?: YAxisBound;
|
||||
max?: YAxisBound;
|
||||
// Set for bar axes anchored at 0, so precision reflects the 0-based range.
|
||||
// Such an axis also gets no gap: pushing it below zero would defeat the zero
|
||||
// anchoring and leave the bars floating above the axis.
|
||||
includeZero?: boolean;
|
||||
// Used to recognise a bounded quantity, so the gap cannot widen the axis past
|
||||
// a limit the data itself never crosses.
|
||||
unit?: string;
|
||||
onFractionDigits: (digits: number) => void;
|
||||
}): {
|
||||
min: (values: YAxisExtentValues) => number | undefined;
|
||||
max: (values: YAxisExtentValues) => number | undefined;
|
||||
boundaryGap: [number, number];
|
||||
splitNumber: number;
|
||||
} {
|
||||
const { min, max, includeZero, unit, onFractionDigits } = options;
|
||||
const naturalMax = unit === "%" ? PERCENT_MAX : undefined;
|
||||
|
||||
const resolveBounds = (values: YAxisExtentValues) => {
|
||||
const resolvedMin = resolveYAxisBound(min, values);
|
||||
const resolvedMax = resolveYAxisBound(max, values);
|
||||
if (
|
||||
includeZero ||
|
||||
!Number.isFinite(values.min) ||
|
||||
!Number.isFinite(values.max)
|
||||
) {
|
||||
return { min: resolvedMin, max: resolvedMax, gap: 0 };
|
||||
}
|
||||
if (values.min === values.max) {
|
||||
const flat = flatSeriesExtent(values.min, [
|
||||
resolvedMin !== undefined,
|
||||
resolvedMax !== undefined,
|
||||
]);
|
||||
// The expansion is a fraction of the magnitude, so a constant series near
|
||||
// the ceiling would otherwise overshoot it too.
|
||||
const flatMax =
|
||||
naturalMax !== undefined && values.max <= naturalMax
|
||||
? Math.min(flat.max, naturalMax)
|
||||
: flat.max;
|
||||
return {
|
||||
min: resolvedMin ?? flat.min,
|
||||
max: resolvedMax ?? flatMax,
|
||||
gap: 0,
|
||||
};
|
||||
}
|
||||
const gap = (values.max - values.min) * GAP_FRACTION_OF_SPAN;
|
||||
// Never let the gap carry a series past a boundary it does not itself cross.
|
||||
const floor = values.min >= 0 ? 0 : undefined;
|
||||
const ceiling =
|
||||
naturalMax !== undefined && values.max <= naturalMax
|
||||
? naturalMax
|
||||
: values.max <= 0
|
||||
? 0
|
||||
: undefined;
|
||||
return {
|
||||
min:
|
||||
resolvedMin ??
|
||||
(floor !== undefined && values.min - floor < gap ? floor : undefined),
|
||||
max:
|
||||
resolvedMax ??
|
||||
(ceiling !== undefined && ceiling - values.max < gap
|
||||
? ceiling
|
||||
: undefined),
|
||||
gap,
|
||||
};
|
||||
};
|
||||
|
||||
const { min, max, includeZero, onFractionDigits } = options;
|
||||
return {
|
||||
// Always emit the key. `setOption` merges the Y axis rather than replacing
|
||||
// it, so a conditionally spread gap would survive a chart switching to a
|
||||
// zero-anchored type and leave its bars floating.
|
||||
boundaryGap: includeZero
|
||||
? [0, 0]
|
||||
: [GAP_FRACTION_OF_SPAN, GAP_FRACTION_OF_SPAN],
|
||||
splitNumber: SPLIT_NUMBER,
|
||||
min: (values) => {
|
||||
const bounds = resolveBounds(values);
|
||||
const resolvedMin = resolveYAxisBound(min, values);
|
||||
const resolvedMax = resolveYAxisBound(max, values);
|
||||
const extentMin = resolvedMin ?? values.min;
|
||||
const extentMax = resolvedMax ?? values.max;
|
||||
onFractionDigits(
|
||||
computeYAxisFractionDigits(
|
||||
bounds.min ?? values.min - bounds.gap,
|
||||
bounds.max ?? values.max + bounds.gap,
|
||||
includeZero
|
||||
)
|
||||
computeYAxisFractionDigits(extentMin, extentMax, includeZero)
|
||||
);
|
||||
return bounds.min;
|
||||
return resolvedMin;
|
||||
},
|
||||
max: (values) => resolveBounds(values).max,
|
||||
max: (values) => resolveYAxisBound(max, values),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { fullEntitiesContext } from "../../data/context";
|
||||
import type { DeviceAutomation } from "../../data/device/device_automation";
|
||||
import {
|
||||
deviceAutomationsEqual,
|
||||
deviceAutomationsSimilar,
|
||||
sortDeviceAutomations,
|
||||
} from "../../data/device/device_automation";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
@@ -179,15 +180,12 @@ export abstract class HaDeviceAutomationPicker<
|
||||
(a, idx) => value === `${a.device_id}_${idx}`
|
||||
);
|
||||
|
||||
const described =
|
||||
automation ?? (this.value?.domain ? this.value : undefined);
|
||||
|
||||
const text = described
|
||||
const text = automation
|
||||
? this._localizeDeviceAutomation(
|
||||
this.hass.localize,
|
||||
this.hass.states,
|
||||
this._entityReg,
|
||||
described
|
||||
automation
|
||||
)
|
||||
: value === NO_AUTOMATION_KEY
|
||||
? this.NO_AUTOMATION_TEXT
|
||||
@@ -197,24 +195,29 @@ export abstract class HaDeviceAutomationPicker<
|
||||
};
|
||||
|
||||
private async _updateDeviceInfo() {
|
||||
// Asking a removed device for its automations fails rather than returning
|
||||
// an empty list.
|
||||
this._automations = this.deviceId
|
||||
? (
|
||||
await this._fetchDeviceAutomations(
|
||||
this.hass.callWS,
|
||||
this.deviceId
|
||||
).catch(() => [] as T[])
|
||||
await this._fetchDeviceAutomations(this.hass.callWS, this.deviceId)
|
||||
).sort(sortDeviceAutomations)
|
||||
: // No device, clear the list of automations
|
||||
[];
|
||||
|
||||
// If there is no value, or if we have changed the device ID, reset the value.
|
||||
// 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(
|
||||
this._automations.length
|
||||
? this._automations[0]
|
||||
: this._createNoAutomation(this.deviceId)
|
||||
equivalent ||
|
||||
(this._automations.length
|
||||
? this._automations[0]
|
||||
: this._createNoAutomation(this.deviceId))
|
||||
);
|
||||
}
|
||||
this._renderEmpty = true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mdiSwapHorizontal } from "@mdi/js";
|
||||
import { mdiAlertOutline } from "@mdi/js";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
type DeviceRegistryEntry,
|
||||
} from "../../data/device/device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import "../ha-alert";
|
||||
@@ -105,14 +104,6 @@ export class HaDevicePicker extends LitElement {
|
||||
@property({ attribute: "hide-clear-icon", type: Boolean })
|
||||
public hideClearIcon = false;
|
||||
|
||||
/**
|
||||
* The split devices that can actually replace the current value, when the
|
||||
* caller knows better than this picker. Narrows the replacement candidates,
|
||||
* so the user is only asked to choose when there is a real choice.
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
public replacementDeviceIds?: string[];
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
|
||||
@@ -196,8 +187,7 @@ export class HaDevicePicker extends LitElement {
|
||||
value: string | undefined,
|
||||
_devices: HomeAssistant["devices"],
|
||||
compositeSplits: DeviceCompositeSplits | undefined,
|
||||
items: (DevicePickerItem | string)[],
|
||||
replacementDeviceIds: string[] | undefined
|
||||
items: (DevicePickerItem | string)[]
|
||||
) => {
|
||||
if (!value || !compositeSplits || this.hass.devices[value]) {
|
||||
return undefined;
|
||||
@@ -213,11 +203,7 @@ export class HaDevicePicker extends LitElement {
|
||||
.filter((item): item is DevicePickerItem => typeof item !== "string")
|
||||
.map((item) => item.id)
|
||||
);
|
||||
const candidates = split.split_ids.filter(
|
||||
(id) =>
|
||||
selectableIds.has(id) &&
|
||||
(!replacementDeviceIds || replacementDeviceIds.includes(id))
|
||||
);
|
||||
const candidates = split.split_ids.filter((id) => selectableIds.has(id));
|
||||
return { candidates, primaryId: split.primary_id };
|
||||
}
|
||||
);
|
||||
@@ -264,26 +250,26 @@ export class HaDevicePicker extends LitElement {
|
||||
};
|
||||
|
||||
private _valueRenderer = memoizeOne(
|
||||
(configEntriesLookup: Record<string, ConfigEntry>, isReplaced: boolean) =>
|
||||
(
|
||||
configEntriesLookup: Record<string, ConfigEntry>,
|
||||
replacementName: string | undefined
|
||||
) =>
|
||||
(value: string) => {
|
||||
const deviceId = value;
|
||||
const device = this.hass.devices[deviceId];
|
||||
|
||||
if (!device) {
|
||||
// The removed device has no name left to show, so say what happened
|
||||
// to it instead. The alert below names the replacements. Without a
|
||||
// replacement, fall back to the normal "not found" display.
|
||||
if (isReplaced) {
|
||||
// When the device was replaced and a replacement is available, show
|
||||
// the replacement device's name. Otherwise fall back to the normal
|
||||
// "not found" display of the raw id.
|
||||
if (replacementName) {
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiSwapHorizontal}
|
||||
style="color: var(--warning-color)"
|
||||
.path=${mdiAlertOutline}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced"
|
||||
)}</span
|
||||
>
|
||||
<span slot="headline">${replacementName}</span>
|
||||
`;
|
||||
}
|
||||
return html`<span slot="headline">${deviceId}</span>`;
|
||||
@@ -413,23 +399,31 @@ export class HaDevicePicker extends LitElement {
|
||||
this.value,
|
||||
this.hass.devices,
|
||||
this._compositeSplits,
|
||||
this._getItems(),
|
||||
this.replacementDeviceIds
|
||||
this._getItems()
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Only treat the value as "replaced" when there is an available
|
||||
// replacement device; otherwise fall back to normal "not found" behavior.
|
||||
const canReplace = !!replacement?.candidates.length;
|
||||
const replacementName = canReplace
|
||||
? computeDeviceName(
|
||||
this.hass.devices[
|
||||
replacement!.primaryId &&
|
||||
replacement!.candidates.includes(replacement!.primaryId)
|
||||
? replacement!.primaryId
|
||||
: replacement!.candidates[0]
|
||||
]
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const valueRenderer = this._valueRenderer(
|
||||
this._configEntryLookup,
|
||||
canReplace
|
||||
replacementName
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.noUnknownState=${canReplace}
|
||||
.hass=${this.hass}
|
||||
.autofocus=${this.autofocus}
|
||||
.disabled=${this.disabled}
|
||||
@@ -449,9 +443,14 @@ export class HaDevicePicker extends LitElement {
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
.valueRenderer=${valueRenderer}
|
||||
.searchKeys=${deviceComboBoxKeys}
|
||||
.unknownItemText=${this.hass.localize(
|
||||
"ui.components.device-picker.unknown"
|
||||
)}
|
||||
.unknownItemText=${
|
||||
replacement?.candidates.length
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_count",
|
||||
{ count: replacement.candidates.length }
|
||||
)
|
||||
: this.hass.localize("ui.components.device-picker.unknown")
|
||||
}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
@@ -465,52 +464,30 @@ export class HaDevicePicker extends LitElement {
|
||||
}) {
|
||||
const { candidates } = replacement;
|
||||
|
||||
// The split devices all inherit the composite's name, so the integration is
|
||||
// what tells them apart.
|
||||
const replacementDevice =
|
||||
candidates.length === 1 ? this.hass.devices[candidates[0]] : undefined;
|
||||
const replacementName = replacementDevice
|
||||
? computeDeviceName(replacementDevice)
|
||||
: undefined;
|
||||
const replacementDomain = replacementDevice?.primary_config_entry
|
||||
? this._configEntryLookup[replacementDevice.primary_config_entry]?.domain
|
||||
: undefined;
|
||||
const replacementName =
|
||||
candidates.length === 1
|
||||
? computeDeviceName(this.hass.devices[candidates[0]])
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<ha-alert alert-type="warning">
|
||||
${
|
||||
replacementName && replacementDomain
|
||||
replacementName
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_by_one_integration",
|
||||
{
|
||||
device: replacementName,
|
||||
integration: domainToName(
|
||||
this.hass.localize,
|
||||
replacementDomain
|
||||
),
|
||||
}
|
||||
"ui.components.device-picker.device_replaced_by_one",
|
||||
{ device: replacementName }
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_by_multiple",
|
||||
{ count: candidates.length }
|
||||
)
|
||||
: replacementName
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_by_one",
|
||||
{ device: replacementName }
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_by_multiple",
|
||||
{ count: candidates.length }
|
||||
)
|
||||
}
|
||||
<ha-button
|
||||
slot="action"
|
||||
appearance="plain"
|
||||
variant="warning"
|
||||
@click=${this._handleReplace}
|
||||
>
|
||||
${
|
||||
candidates.length === 1
|
||||
? this.hass.localize("ui.components.device-picker.replace_update")
|
||||
: this.hass.localize("ui.components.device-picker.replace_choose")
|
||||
}
|
||||
${this.hass.localize("ui.components.device-picker.replace_device")}
|
||||
</ha-button>
|
||||
</ha-alert>
|
||||
`;
|
||||
@@ -521,8 +498,7 @@ export class HaDevicePicker extends LitElement {
|
||||
this.value,
|
||||
this.hass.devices,
|
||||
this._compositeSplits,
|
||||
this._getItems(),
|
||||
this.replacementDeviceIds
|
||||
this._getItems()
|
||||
);
|
||||
if (!replacement?.candidates.length) {
|
||||
return;
|
||||
|
||||
@@ -7,9 +7,12 @@ import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
|
||||
import {
|
||||
ENTITY_NAME_TYPES,
|
||||
type EntityNameItem,
|
||||
type EntityNameType,
|
||||
} from "../../common/entity/compute_entity_name_display";
|
||||
import { getEntityContext } from "../../common/entity/context/get_entity_context";
|
||||
import type { EntityNameType } from "../../common/translations/entity-state";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../chips/ha-assist-chip";
|
||||
@@ -35,9 +38,7 @@ const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
const KNOWN_TYPES = new Set(["entity", "device", "area", "floor"]);
|
||||
|
||||
const UNIQUE_TYPES = new Set(["entity", "device", "area", "floor"]);
|
||||
const KNOWN_TYPES = new Set<string>(ENTITY_NAME_TYPES);
|
||||
|
||||
const formatOptionValue = (item: EntityNameItem) => {
|
||||
if (item.type === "text" && item.text) {
|
||||
@@ -387,6 +388,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
);
|
||||
|
||||
if (context.device) options.add("device");
|
||||
if (context.parentDevice) options.add("parent_device");
|
||||
if (context.area) options.add("area");
|
||||
if (context.floor) options.add("floor");
|
||||
return options;
|
||||
@@ -399,9 +401,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
const types = this._validTypes(entityId);
|
||||
|
||||
const items = (
|
||||
["entity", "device", "area", "floor"] as const
|
||||
).map<PickerComboBoxItem>((name) => {
|
||||
const items = ENTITY_NAME_TYPES.map<PickerComboBoxItem>((name) => {
|
||||
const stateObj = this.hass.states[entityId];
|
||||
const isValid = types.has(name);
|
||||
const primary = this.hass.localize(
|
||||
@@ -464,7 +464,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
const excludedValues = new Set(
|
||||
this._items
|
||||
.filter((item) => UNIQUE_TYPES.has(item.type))
|
||||
.filter((item) => KNOWN_TYPES.has(item.type))
|
||||
.map((item) => formatOptionValue(item))
|
||||
);
|
||||
|
||||
|
||||
@@ -178,6 +178,17 @@ export class HaStateContentPicker extends LitElement {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (context.parentDevice) {
|
||||
contextItems.push({
|
||||
id: "parent_device_name",
|
||||
primary: this.hass.localize(
|
||||
"ui.components.state-content-picker.parent_device_name"
|
||||
),
|
||||
sorting_label: this.hass.localize(
|
||||
"ui.components.state-content-picker.parent_device_name"
|
||||
),
|
||||
});
|
||||
}
|
||||
if (context.area) {
|
||||
contextItems.push({
|
||||
id: "area_name",
|
||||
|
||||
@@ -16,10 +16,10 @@ import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import {
|
||||
getStatisticIds,
|
||||
getStatisticLabel,
|
||||
type StatisticsMetaData,
|
||||
} from "../../data/recorder";
|
||||
import { getStatisticIds } from "../../data/recorder_statistic_ids";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import { documentationUrl } from "../../util/documentation-url";
|
||||
import "../ha-combo-box-item";
|
||||
@@ -52,6 +52,7 @@ const SEARCH_KEYS = [
|
||||
{ name: "search_labels.entityName", weight: 10 },
|
||||
{ name: "search_labels.friendlyName", weight: 9 },
|
||||
{ name: "search_labels.deviceName", weight: 8 },
|
||||
{ name: "search_labels.parentDeviceName", weight: 6 },
|
||||
{ name: "search_labels.areaName", weight: 6 },
|
||||
{ name: "search_labels.domainName", weight: 4 },
|
||||
{ name: "statisticId", weight: 3 },
|
||||
@@ -292,17 +293,27 @@ export class HaStatisticPicker extends LitElement {
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || id;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
@@ -318,6 +329,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
},
|
||||
@@ -395,14 +407,20 @@ export class HaStatisticPicker extends LitElement {
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
|
||||
if (stateObj) {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(
|
||||
this.hass.language,
|
||||
@@ -410,7 +428,11 @@ export class HaStatisticPicker extends LitElement {
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || statisticId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
@@ -427,6 +449,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
statisticId,
|
||||
|
||||
@@ -90,7 +90,7 @@ class HaAlert extends LitElement {
|
||||
static styles = css`
|
||||
.issue-type {
|
||||
position: relative;
|
||||
padding: var(--ha-alert-padding, 8px);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
}
|
||||
.icon {
|
||||
|
||||
@@ -197,17 +197,27 @@ export class HaAreaControlsPicker extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass!.entities,
|
||||
this.hass!.devices,
|
||||
this.hass!.areas,
|
||||
this.hass!.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass!.entities,
|
||||
this.hass!.devices,
|
||||
this.hass!.areas,
|
||||
this.hass!.floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { repeat } from "lit/directives/repeat";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { STATE_RUNNING } from "home-assistant-js-websocket";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeStateName } from "../common/entity/compute_state_name";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import {
|
||||
@@ -150,7 +149,6 @@ export class HaCameraStream extends LitElement {
|
||||
objectFit: this.fitMode,
|
||||
})}
|
||||
alt=${`Preview of the ${computeStateName(this.stateObj)} camera.`}
|
||||
@load=${this._handleImageLoad}
|
||||
/>`;
|
||||
}
|
||||
|
||||
@@ -216,10 +214,6 @@ export class HaCameraStream extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _handleImageLoad() {
|
||||
fireEvent(this, "load");
|
||||
}
|
||||
|
||||
private _handleHlsStreams(ev: CustomEvent) {
|
||||
this._hlsStreams = ev.detail;
|
||||
}
|
||||
|
||||
@@ -761,6 +761,9 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
const deviceName = context.device
|
||||
? computeDeviceName(context.device)
|
||||
: undefined;
|
||||
const parentDeviceName = context.parentDevice
|
||||
? computeDeviceName(context.parentDevice)
|
||||
: undefined;
|
||||
const areaName = context.area ? computeAreaName(context.area) : undefined;
|
||||
const floorName = context.floor
|
||||
? computeFloorName(context.floor)
|
||||
@@ -788,6 +791,15 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
});
|
||||
}
|
||||
|
||||
if (parentDeviceName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize(
|
||||
"ui.components.device-picker.parent_device"
|
||||
),
|
||||
value: parentDeviceName,
|
||||
});
|
||||
}
|
||||
|
||||
if (deviceName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize("ui.components.device-picker.device"),
|
||||
|
||||
@@ -101,7 +101,7 @@ export class HaControlSelect extends LitElement {
|
||||
|
||||
private _handleOptionClick(ev: MouseEvent) {
|
||||
if (this.disabled) return;
|
||||
const value = (ev.currentTarget as any).value;
|
||||
const value = (ev.target 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.currentTarget as any).value;
|
||||
const value = (ev.target 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.currentTarget as any).value;
|
||||
const value = (ev.target as any).value;
|
||||
this._activeIndex = this.options?.findIndex(
|
||||
(option) => option.value === value
|
||||
);
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -95,10 +95,6 @@ export class HaDrawer extends LitElement {
|
||||
}
|
||||
|
||||
private _handleAfterHide(ev: Event) {
|
||||
// Ignore wa-after-hide from nested Web Awesome components (e.g. tooltips)
|
||||
if (ev.target !== ev.currentTarget) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
this.open = false;
|
||||
fireEvent(this, "hass-drawer-closed");
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,519 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,6 @@
|
||||
import {
|
||||
getSelectorInitialValue,
|
||||
getSelectorInitialValueOrUndefined,
|
||||
} from "./get-selector-initial-value";
|
||||
import type { Selector } from "../../data/selector";
|
||||
import type { HaFormData, HaFormSchema } from "./types";
|
||||
|
||||
interface ComputeInitialHaFormDataOptions {
|
||||
skipUnsupportedSelectors?: boolean;
|
||||
}
|
||||
|
||||
const setDefaultValue = (
|
||||
field: HaFormSchema,
|
||||
value: HaFormData | undefined
|
||||
@@ -25,8 +18,7 @@ const setDefaultValue = (
|
||||
};
|
||||
|
||||
export const computeInitialHaFormData = (
|
||||
schema: HaFormSchema[] | readonly HaFormSchema[],
|
||||
options?: ComputeInitialHaFormDataOptions
|
||||
schema: HaFormSchema[] | readonly HaFormSchema[]
|
||||
): Record<string, any> => {
|
||||
const data = {};
|
||||
schema.forEach((field) => {
|
||||
@@ -41,7 +33,7 @@ export const computeInitialHaFormData = (
|
||||
} else if ("default" in field) {
|
||||
data[field.name] = setDefaultValue(field, field.default);
|
||||
} else if (field.type === "expandable") {
|
||||
const expandableData = computeInitialHaFormData(field.schema, options);
|
||||
const expandableData = computeInitialHaFormData(field.schema);
|
||||
if (field.required || Object.keys(expandableData).length) {
|
||||
// Only add expandable data if it's required or any of its children have initial values.
|
||||
data[field.name] = expandableData;
|
||||
@@ -70,11 +62,104 @@ export const computeInitialHaFormData = (
|
||||
seconds: 0,
|
||||
};
|
||||
} else if ("selector" in field) {
|
||||
const initialValue = options?.skipUnsupportedSelectors
|
||||
? getSelectorInitialValueOrUndefined(field.selector)
|
||||
: getSelectorInitialValue(field.selector);
|
||||
if (initialValue !== undefined) {
|
||||
data[field.name] = initialValue;
|
||||
const selector: Selector = field.selector;
|
||||
|
||||
if ("device" in selector) {
|
||||
data[field.name] = selector.device?.multiple ? [] : "";
|
||||
} else if ("entity" in selector) {
|
||||
data[field.name] = selector.entity?.multiple ? [] : "";
|
||||
} else if ("area" in selector) {
|
||||
data[field.name] = selector.area?.multiple ? [] : "";
|
||||
} else if ("label" in selector) {
|
||||
data[field.name] = selector.label?.multiple ? [] : "";
|
||||
} else if ("boolean" in selector) {
|
||||
data[field.name] = false;
|
||||
} else if (
|
||||
"addon" in selector ||
|
||||
"attribute" in selector ||
|
||||
"file" in selector ||
|
||||
"icon" in selector ||
|
||||
"serial_port" in selector ||
|
||||
"template" in selector ||
|
||||
"text" in selector ||
|
||||
"theme" in selector ||
|
||||
"object" in selector
|
||||
) {
|
||||
data[field.name] = "";
|
||||
} else if ("number" in selector) {
|
||||
data[field.name] = selector.number?.min ?? 0;
|
||||
} else if ("select" in selector) {
|
||||
if (selector.select?.options.length) {
|
||||
const firstOption = selector.select.options[0];
|
||||
const val =
|
||||
typeof firstOption === "string" ? firstOption : firstOption.value;
|
||||
data[field.name] = selector.select.multiple ? [val] : val;
|
||||
}
|
||||
} else if ("country" in selector) {
|
||||
if (selector.country?.countries?.length) {
|
||||
data[field.name] = selector.country.countries[0];
|
||||
}
|
||||
} else if ("language" in selector) {
|
||||
if (selector.language?.languages?.length) {
|
||||
data[field.name] = selector.language.languages[0];
|
||||
}
|
||||
} else if ("duration" in selector) {
|
||||
data[field.name] = {
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
};
|
||||
} else if ("time" in selector) {
|
||||
data[field.name] = "00:00:00";
|
||||
} else if ("date" in selector || "datetime" in selector) {
|
||||
const now = new Date().toISOString().slice(0, 10);
|
||||
data[field.name] = `${now}T00:00:00`;
|
||||
} else if ("color_rgb" in selector) {
|
||||
data[field.name] = [0, 0, 0];
|
||||
} else if ("color_temp" in selector) {
|
||||
data[field.name] = selector.color_temp?.min_mireds ?? 153;
|
||||
} else if (
|
||||
"action" in selector ||
|
||||
"trigger" in selector ||
|
||||
"condition" in selector
|
||||
) {
|
||||
data[field.name] = [];
|
||||
} else if ("media" in selector || "target" in selector) {
|
||||
data[field.name] = {};
|
||||
} else if ("state" in selector) {
|
||||
data[field.name] = selector.state?.multiple ? [] : "";
|
||||
} else if ("choose" in selector) {
|
||||
const firstChoice = Object.keys(selector.choose.choices)[0];
|
||||
if (!firstChoice) {
|
||||
data[field.name] = {};
|
||||
} else {
|
||||
data[field.name] = {
|
||||
active_choice: firstChoice,
|
||||
[firstChoice]: computeInitialHaFormData([
|
||||
{
|
||||
name: firstChoice,
|
||||
selector: selector.choose.choices[firstChoice].selector,
|
||||
},
|
||||
])[firstChoice],
|
||||
};
|
||||
}
|
||||
} else if ("numeric_threshold" in selector) {
|
||||
const mode = selector.numeric_threshold?.mode ?? "crossed";
|
||||
const type = mode === "changed" ? "any" : "above";
|
||||
data[field.name] =
|
||||
type === "any"
|
||||
? { type }
|
||||
: {
|
||||
type,
|
||||
value: {
|
||||
number: selector.numeric_threshold?.number?.min ?? 0,
|
||||
active_choice: "number",
|
||||
},
|
||||
};
|
||||
} else {
|
||||
throw new Error(
|
||||
`Selector ${Object.keys(selector)[0]} not supported in initial form data`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,89 +1,25 @@
|
||||
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
|
||||
import type {
|
||||
Selector,
|
||||
SelectorForType,
|
||||
SelectorType,
|
||||
} from "../../data/selector";
|
||||
|
||||
type SelectorFallbackValues = {
|
||||
[T in SelectorType]: ((selector: SelectorForType<T>) => unknown) | undefined;
|
||||
};
|
||||
|
||||
const SELECTOR_FALLBACK_VALUES = {
|
||||
action: undefined,
|
||||
addon: undefined,
|
||||
automation_behavior: undefined,
|
||||
app: undefined,
|
||||
area: undefined,
|
||||
areas_display: undefined,
|
||||
attribute: undefined,
|
||||
assist_pipeline: undefined,
|
||||
boolean: () => false,
|
||||
choose: undefined,
|
||||
color_rgb: undefined,
|
||||
condition: undefined,
|
||||
config_entry: undefined,
|
||||
conversation_agent: undefined,
|
||||
constant: (selector) => selector.constant?.value,
|
||||
country: undefined,
|
||||
date: undefined,
|
||||
datetime: undefined,
|
||||
device: undefined,
|
||||
device_class: undefined,
|
||||
duration: undefined,
|
||||
entity: undefined,
|
||||
entity_name: undefined,
|
||||
statistic: undefined,
|
||||
file: undefined,
|
||||
floor: undefined,
|
||||
label: undefined,
|
||||
language: undefined,
|
||||
navigation: undefined,
|
||||
number: (selector) => selector.number?.min ?? 0,
|
||||
numeric_threshold: undefined,
|
||||
object: undefined,
|
||||
period: undefined,
|
||||
qr_code: undefined,
|
||||
select: undefined,
|
||||
selector: undefined,
|
||||
serial_port: undefined,
|
||||
state: undefined,
|
||||
backup_location: undefined,
|
||||
stt: undefined,
|
||||
target: undefined,
|
||||
template: undefined,
|
||||
text: undefined,
|
||||
time: undefined,
|
||||
icon: undefined,
|
||||
media: undefined,
|
||||
theme: undefined,
|
||||
timezone: undefined,
|
||||
button_toggle: undefined,
|
||||
trigger: undefined,
|
||||
tts: undefined,
|
||||
tts_voice: undefined,
|
||||
location: undefined,
|
||||
color_temp: (selector) => {
|
||||
if (selector.color_temp?.unit === "kelvin") {
|
||||
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
|
||||
}
|
||||
|
||||
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
|
||||
},
|
||||
ui_action: undefined,
|
||||
ui_clock_date_format: undefined,
|
||||
ui_color: undefined,
|
||||
ui_state_content: undefined,
|
||||
ui_time_format: undefined,
|
||||
} satisfies SelectorFallbackValues;
|
||||
import type { Selector } from "../../data/selector";
|
||||
|
||||
/**
|
||||
* Value a selector already displays when no field value is set.
|
||||
* Used when enabling an optional service/trigger/condition field.
|
||||
*/
|
||||
export const getSelectorFallbackValue = (selector: Selector): unknown => {
|
||||
const type = Object.keys(selector)[0] as SelectorType;
|
||||
const fallbackValue = SELECTOR_FALLBACK_VALUES[type];
|
||||
|
||||
return fallbackValue?.(selector as never);
|
||||
if ("constant" in selector) {
|
||||
return selector.constant?.value;
|
||||
}
|
||||
if ("boolean" in selector) {
|
||||
return false;
|
||||
}
|
||||
if ("number" in selector) {
|
||||
return selector.number?.min ?? 0;
|
||||
}
|
||||
if ("color_temp" in selector) {
|
||||
if (selector.color_temp?.unit === "kelvin") {
|
||||
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
|
||||
}
|
||||
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
|
||||
import type {
|
||||
Selector,
|
||||
SelectorForType,
|
||||
SelectorType,
|
||||
} from "../../data/selector";
|
||||
|
||||
type SelectorInitialValues = {
|
||||
[T in SelectorType]: ((selector: SelectorForType<T>) => unknown) | undefined;
|
||||
};
|
||||
|
||||
const SELECTOR_INITIAL_VALUES = {
|
||||
action: () => [],
|
||||
addon: () => "",
|
||||
automation_behavior: undefined,
|
||||
app: undefined,
|
||||
area: (selector) => (selector.area?.multiple ? [] : ""),
|
||||
areas_display: undefined,
|
||||
attribute: () => "",
|
||||
assist_pipeline: undefined,
|
||||
boolean: () => false,
|
||||
choose: (selector) => {
|
||||
const firstChoice = Object.keys(selector.choose.choices)[0];
|
||||
|
||||
if (!firstChoice) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const childValue = getSelectorInitialValueOrUndefined(
|
||||
selector.choose.choices[firstChoice].selector
|
||||
);
|
||||
|
||||
return childValue === undefined
|
||||
? { active_choice: firstChoice }
|
||||
: { active_choice: firstChoice, [firstChoice]: childValue };
|
||||
},
|
||||
color_rgb: () => [0, 0, 0],
|
||||
condition: () => [],
|
||||
config_entry: undefined,
|
||||
conversation_agent: undefined,
|
||||
constant: (selector) => selector.constant?.value,
|
||||
country: (selector) => selector.country?.countries?.[0],
|
||||
date: () => new Date().toISOString().slice(0, 10),
|
||||
datetime: () => `${new Date().toISOString().slice(0, 10)} 00:00:00`,
|
||||
device: (selector) => (selector.device?.multiple ? [] : ""),
|
||||
device_class: (selector) =>
|
||||
selector.device_class?.multiple ? [] : undefined,
|
||||
duration: () => ({
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
}),
|
||||
entity: (selector) => (selector.entity?.multiple ? [] : ""),
|
||||
entity_name: undefined,
|
||||
statistic: undefined,
|
||||
file: () => "",
|
||||
floor: undefined,
|
||||
label: (selector) => (selector.label?.multiple ? [] : ""),
|
||||
language: (selector) => selector.language?.languages?.[0],
|
||||
navigation: undefined,
|
||||
number: (selector) => selector.number?.min ?? 0,
|
||||
numeric_threshold: (selector) => {
|
||||
const mode = selector.numeric_threshold?.mode ?? "crossed";
|
||||
const type = mode === "changed" ? "any" : "above";
|
||||
|
||||
return type === "any"
|
||||
? { type }
|
||||
: {
|
||||
type,
|
||||
value: {
|
||||
number: selector.numeric_threshold?.number?.min ?? 0,
|
||||
active_choice: "number",
|
||||
},
|
||||
};
|
||||
},
|
||||
object: (selector) => (selector.object?.multiple ? [] : ""),
|
||||
period: undefined,
|
||||
qr_code: undefined,
|
||||
select: (selector) => {
|
||||
const select = selector.select;
|
||||
|
||||
if (!select?.options.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const firstOption = select.options[0];
|
||||
const value =
|
||||
typeof firstOption === "string" ? firstOption : firstOption.value;
|
||||
|
||||
return select.multiple ? [value] : value;
|
||||
},
|
||||
selector: undefined,
|
||||
serial_port: () => "",
|
||||
state: (selector) => (selector.state?.multiple ? [] : ""),
|
||||
backup_location: undefined,
|
||||
stt: undefined,
|
||||
target: () => ({}),
|
||||
template: () => "",
|
||||
text: (selector) => (selector.text?.multiple ? [] : ""),
|
||||
time: () => "00:00:00",
|
||||
icon: () => "",
|
||||
media: (selector) => (selector.media?.multiple ? [] : {}),
|
||||
theme: () => "",
|
||||
timezone: undefined,
|
||||
button_toggle: undefined,
|
||||
trigger: () => [],
|
||||
tts: undefined,
|
||||
tts_voice: undefined,
|
||||
location: undefined,
|
||||
color_temp: (selector) => {
|
||||
if (selector.color_temp?.unit === "kelvin") {
|
||||
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
|
||||
}
|
||||
|
||||
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
|
||||
},
|
||||
ui_action: undefined,
|
||||
ui_clock_date_format: undefined,
|
||||
ui_color: undefined,
|
||||
ui_state_content: undefined,
|
||||
ui_time_format: undefined,
|
||||
} satisfies SelectorInitialValues;
|
||||
|
||||
export const getSelectorInitialValueOrUndefined = (
|
||||
selector: Selector
|
||||
): unknown => {
|
||||
const type = Object.keys(selector)[0] as SelectorType;
|
||||
return SELECTOR_INITIAL_VALUES[type]?.(selector as never);
|
||||
};
|
||||
|
||||
export const getSelectorInitialValue = (selector: Selector): unknown => {
|
||||
const type = Object.keys(selector)[0] as SelectorType;
|
||||
const initialValue = SELECTOR_INITIAL_VALUES[type];
|
||||
|
||||
if (!initialValue) {
|
||||
throw new Error(`Selector ${type} not supported in initial form data`);
|
||||
}
|
||||
|
||||
return initialValue(selector as never);
|
||||
};
|
||||
@@ -111,11 +111,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
|
||||
|
||||
// Skip the "unknown value" highlight and note for a value that is not in the
|
||||
// list but that the value renderer presents on its own.
|
||||
@property({ type: Boolean, attribute: "no-unknown-state" })
|
||||
public noUnknownState = false;
|
||||
|
||||
@query(".container") private _containerElement?: HTMLDivElement;
|
||||
|
||||
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
|
||||
@@ -153,10 +148,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
private _unsubscribeTinyKeys?: () => void;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (
|
||||
changedProperties.has("value") ||
|
||||
changedProperties.has("noUnknownState")
|
||||
) {
|
||||
if (changedProperties.has("value")) {
|
||||
this._setUnknownValue();
|
||||
}
|
||||
}
|
||||
@@ -295,7 +287,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
private _setUnknownValue = () => {
|
||||
const items = this.getItems();
|
||||
if (
|
||||
this.noUnknownState ||
|
||||
this.allowCustomValue ||
|
||||
this.value === undefined ||
|
||||
this.value === null ||
|
||||
|
||||
@@ -1,445 +0,0 @@
|
||||
import { mdiDelete, mdiPlayBox, mdiPlus } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import type { MediaPickedEvent } from "../data/media-player";
|
||||
import {
|
||||
MediaClassBrowserSettings,
|
||||
MediaPlayerEntityFeature,
|
||||
} from "../data/media-player";
|
||||
import type { MediaSelector, MediaSelectorValue } from "../data/selector";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-alert";
|
||||
import "./ha-form/ha-form";
|
||||
import type { SchemaUnion } from "./ha-form/types";
|
||||
import "./media-player/ha-media-browser-thumbnail";
|
||||
import { showMediaBrowserDialog } from "./media-player/show-media-browser-dialog";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import "./ha-picture-upload";
|
||||
|
||||
const MANUAL_SCHEMA = [
|
||||
{ name: "media_content_id", required: false, selector: { text: {} } },
|
||||
{ name: "media_content_type", required: false, selector: { text: {} } },
|
||||
] as const;
|
||||
|
||||
const INCLUDE_DOMAINS = ["media_player"];
|
||||
|
||||
const EMPTY_FORM = {};
|
||||
|
||||
@customElement("ha-media-item-picker")
|
||||
export class HaMediaItemPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: MediaSelector;
|
||||
|
||||
@property({ attribute: false }) public value?: MediaSelectorValue;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public required = true;
|
||||
|
||||
@property({ type: Boolean }) public multiple = false;
|
||||
|
||||
@property({ attribute: false }) public context?: {
|
||||
filter_entity?: string | string[];
|
||||
};
|
||||
|
||||
private _contextEntities: string[] | undefined;
|
||||
|
||||
private get _hasAccept(): boolean {
|
||||
return !!this.selector?.media?.accept?.length;
|
||||
}
|
||||
|
||||
willUpdate(changedProps: PropertyValues<this>) {
|
||||
if (changedProps.has("context")) {
|
||||
if (!this._hasAccept) {
|
||||
this._contextEntities = ensureArray(this.context?.filter_entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const entityId = this._getActiveEntityId();
|
||||
|
||||
const stateObj = entityId ? this.hass.states[entityId] : undefined;
|
||||
|
||||
const supportsBrowse =
|
||||
!entityId ||
|
||||
(stateObj &&
|
||||
supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA));
|
||||
|
||||
if (this.selector.media?.image_upload && !this.value) {
|
||||
return html`${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-picture-upload
|
||||
.hass=${this.hass}
|
||||
.value=${null}
|
||||
.contentIdHelper=${this.selector.media?.content_id_helper}
|
||||
select-media
|
||||
full-media
|
||||
@media-picked=${this._pictureUploadMediaPicked}
|
||||
></ha-picture-upload>`;
|
||||
}
|
||||
|
||||
const hideEntityPicker =
|
||||
this._hasAccept ||
|
||||
(this._contextEntities && this._contextEntities.length <= 1);
|
||||
return html`
|
||||
${
|
||||
hideEntityPicker
|
||||
? nothing
|
||||
: html`
|
||||
<ha-entity-picker
|
||||
.value=${entityId}
|
||||
.label=${
|
||||
this.label ||
|
||||
this.hass.localize(
|
||||
"ui.components.selectors.media.pick_media_player"
|
||||
)
|
||||
}
|
||||
.disabled=${this.disabled}
|
||||
.helper=${this.helper}
|
||||
.required=${this.required}
|
||||
.hideClearIcon=${!!this._contextEntities}
|
||||
.includeDomains=${INCLUDE_DOMAINS}
|
||||
.includeEntities=${this._contextEntities}
|
||||
.allowCustomEntity=${!this._contextEntities}
|
||||
@value-changed=${this._entityChanged}
|
||||
></ha-entity-picker>
|
||||
`
|
||||
}
|
||||
${
|
||||
!supportsBrowse
|
||||
? html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-alert>
|
||||
${this.hass.localize(
|
||||
"ui.components.selectors.media.browse_not_supported"
|
||||
)}
|
||||
</ha-alert>
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this.value || EMPTY_FORM}
|
||||
.schema=${MANUAL_SCHEMA}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.computeHelper=${this._computeHelperCallback}
|
||||
></ha-form>
|
||||
`
|
||||
: html`${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-card
|
||||
outlined
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label=${
|
||||
!this.value?.media_content_id
|
||||
? this.hass.localize(
|
||||
"ui.components.selectors.media.pick_media"
|
||||
)
|
||||
: this.value.metadata?.title || this.value.media_content_id
|
||||
}
|
||||
@click=${this._pickMedia}
|
||||
@keydown=${this._handleKeyDown}
|
||||
class=${
|
||||
this.disabled || (!entityId && !this._hasAccept)
|
||||
? "disabled"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<div class="content-container">
|
||||
<div class="thumbnail">
|
||||
${
|
||||
this.value?.metadata?.thumbnail
|
||||
? html`
|
||||
<div
|
||||
class="${classMap({
|
||||
"centered-image":
|
||||
!!this.value.metadata.media_class &&
|
||||
["app", "directory"].includes(
|
||||
this.value.metadata.media_class
|
||||
),
|
||||
})}
|
||||
image"
|
||||
>
|
||||
<ha-media-browser-thumbnail
|
||||
.hass=${this.hass}
|
||||
.url=${this.value.metadata.thumbnail}
|
||||
></ha-media-browser-thumbnail>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="icon-holder image">
|
||||
<ha-svg-icon
|
||||
class="folder"
|
||||
.path=${
|
||||
!this.value?.media_content_id
|
||||
? mdiPlus
|
||||
: this.value?.metadata?.media_class
|
||||
? MediaClassBrowserSettings[
|
||||
this.value.metadata.media_class ===
|
||||
"directory"
|
||||
? this.value.metadata
|
||||
.children_media_class ||
|
||||
this.value.metadata.media_class
|
||||
: this.value.metadata.media_class
|
||||
].icon
|
||||
: mdiPlayBox
|
||||
}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
<div class="title">
|
||||
${
|
||||
!this.value?.media_content_id
|
||||
? this.hass.localize(
|
||||
"ui.components.selectors.media.pick_media"
|
||||
)
|
||||
: this.value.metadata?.title ||
|
||||
this.value.media_content_id
|
||||
}
|
||||
</div>
|
||||
${
|
||||
!this.multiple || !this.value
|
||||
? nothing
|
||||
: html`
|
||||
<div class="delete">
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.common.delete")}
|
||||
.path=${mdiDelete}
|
||||
@click=${this._clearValue}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
${
|
||||
this.selector.media?.image_upload
|
||||
? html`<div>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
variant="danger"
|
||||
@click=${this._clearValue}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.components.picture-upload.clear_picture"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>`
|
||||
: nothing
|
||||
}`
|
||||
}
|
||||
${
|
||||
hideEntityPicker && this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<typeof MANUAL_SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(`ui.components.selectors.media.${schema.name}`);
|
||||
|
||||
private _computeHelperCallback = (
|
||||
schema: SchemaUnion<typeof MANUAL_SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(`ui.components.selectors.media.${schema.name}_detail`);
|
||||
|
||||
private _entityChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
if (!this._hasAccept && this.context?.filter_entity) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
media_content_id: "",
|
||||
media_content_type: "",
|
||||
metadata: {
|
||||
browse_entity_id: ev.detail.value,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
entity_id: ev.detail.value,
|
||||
media_content_id: "",
|
||||
media_content_type: "",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _pickMedia() {
|
||||
showMediaBrowserDialog(this, {
|
||||
action: "pick",
|
||||
entityId: this._getActiveEntityId(),
|
||||
navigateIds: this.value?.metadata?.navigateIds,
|
||||
accept: this.selector.media?.accept,
|
||||
defaultId: this.value?.media_content_id,
|
||||
defaultType: this.value?.media_content_type,
|
||||
hideContentType: this.selector.media?.hide_content_type,
|
||||
contentIdHelper: this.selector.media?.content_id_helper,
|
||||
mediaPickedCallback: (pickedMedia: MediaPickedEvent) => {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.value,
|
||||
media_content_id: pickedMedia.item.media_content_id,
|
||||
media_content_type: pickedMedia.item.media_content_type,
|
||||
metadata: {
|
||||
title: pickedMedia.item.title,
|
||||
thumbnail: pickedMedia.item.thumbnail,
|
||||
media_class: pickedMedia.item.media_class,
|
||||
children_media_class: pickedMedia.item.children_media_class,
|
||||
navigateIds: pickedMedia.navigateIds?.map((id) => ({
|
||||
media_content_type: id.media_content_type,
|
||||
media_content_id: id.media_content_id,
|
||||
})),
|
||||
...(!this._hasAccept && this.context?.filter_entity
|
||||
? { browse_entity_id: this._getActiveEntityId() }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _getActiveEntityId(): string | undefined {
|
||||
const metaId = this.value?.metadata?.browse_entity_id;
|
||||
return (
|
||||
this.value?.entity_id ||
|
||||
(metaId && this._contextEntities?.includes(metaId) && metaId) ||
|
||||
this._contextEntities?.[0]
|
||||
);
|
||||
}
|
||||
|
||||
private _handleKeyDown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
this._pickMedia();
|
||||
}
|
||||
}
|
||||
|
||||
private _pictureUploadMediaPicked(ev) {
|
||||
const pickedMedia = ev.detail as MediaPickedEvent;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.value,
|
||||
media_content_id: pickedMedia.item.media_content_id,
|
||||
media_content_type: pickedMedia.item.media_content_type,
|
||||
metadata: {
|
||||
title: pickedMedia.item.title,
|
||||
thumbnail: pickedMedia.item.thumbnail,
|
||||
media_class: pickedMedia.item.media_class,
|
||||
children_media_class: pickedMedia.item.children_media_class,
|
||||
navigateIds: pickedMedia.navigateIds?.map((id) => ({
|
||||
media_content_type: id.media_content_type,
|
||||
media_content_id: id.media_content_id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _clearValue(ev) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "value-changed", { value: undefined });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-entity-picker {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
ha-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
transition: background-color 180ms ease-in-out;
|
||||
min-height: 56px;
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
ha-card:hover:not(.disabled),
|
||||
ha-card:focus:not(.disabled) {
|
||||
background-color: var(--state-icon-hover-color, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
ha-card:focus {
|
||||
outline: none;
|
||||
}
|
||||
ha-card.disabled {
|
||||
pointer-events: none;
|
||||
color: var(--disabled-text-color);
|
||||
}
|
||||
.content-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
ha-card .thumbnail {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
ha-card .image {
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
.folder {
|
||||
--mdc-icon-size: 24px;
|
||||
}
|
||||
.title {
|
||||
font-size: var(--ha-font-size-m);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
--ha-media-browser-thumbnail-fit: cover;
|
||||
}
|
||||
.centered-image {
|
||||
margin: 4px;
|
||||
--ha-media-browser-thumbnail-fit: contain;
|
||||
}
|
||||
.icon-holder {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-media-item-picker": HaMediaItemPicker;
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,33 @@
|
||||
import { html, LitElement, type TemplateResult } from "lit";
|
||||
import { mdiPlayBox, mdiPlus } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { supportsFeature } from "../../common/entity/supports-feature";
|
||||
import type { MediaPickedEvent } from "../../data/media-player";
|
||||
import {
|
||||
MediaClassBrowserSettings,
|
||||
MediaPlayerEntityFeature,
|
||||
} from "../../data/media-player";
|
||||
import type { MediaSelector, MediaSelectorValue } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-media-item-picker";
|
||||
import "../ha-alert";
|
||||
import "../ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../ha-form/types";
|
||||
import "../media-player/ha-media-browser-thumbnail";
|
||||
import { showMediaBrowserDialog } from "../media-player/show-media-browser-dialog";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../ha-picture-upload";
|
||||
|
||||
const MANUAL_SCHEMA = [
|
||||
{ name: "media_content_id", required: false, selector: { text: {} } },
|
||||
{ name: "media_content_type", required: false, selector: { text: {} } },
|
||||
] as const;
|
||||
|
||||
const INCLUDE_DOMAINS = ["media_player"];
|
||||
|
||||
const EMPTY_FORM = {};
|
||||
|
||||
@customElement("ha-selector-media")
|
||||
export class HaMediaSelector extends LitElement {
|
||||
@@ -12,8 +35,7 @@ export class HaMediaSelector extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public selector!: MediaSelector;
|
||||
|
||||
@property({ attribute: false }) public value?:
|
||||
MediaSelectorValue | MediaSelectorValue[];
|
||||
@property({ attribute: false }) public value?: MediaSelectorValue;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@@ -27,83 +49,367 @@ export class HaMediaSelector extends LitElement {
|
||||
filter_entity?: string | string[];
|
||||
};
|
||||
|
||||
private _renderPicker(
|
||||
helper?: string,
|
||||
value?: MediaSelectorValue,
|
||||
index?: number,
|
||||
changeCallback?
|
||||
): TemplateResult {
|
||||
return html`<ha-media-item-picker
|
||||
.hass=${this.hass}
|
||||
.context=${this.context}
|
||||
.selector=${this.selector}
|
||||
.value=${value}
|
||||
.label=${index == null ? this.label : undefined}
|
||||
.helper=${helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.multiple=${index != null}
|
||||
@value-changed=${changeCallback}
|
||||
.idx=${index}
|
||||
></ha-media-item-picker>`;
|
||||
private _contextEntities: string[] | undefined;
|
||||
|
||||
private get _hasAccept(): boolean {
|
||||
return !!this.selector?.media?.accept?.length;
|
||||
}
|
||||
|
||||
private _multiValueChanged = (event: CustomEvent) => {
|
||||
event.stopPropagation();
|
||||
const target = event.currentTarget as any;
|
||||
if (!target) return;
|
||||
const idx = target.idx;
|
||||
let newValue: MediaSelectorValue[] | undefined;
|
||||
if (idx === undefined) return;
|
||||
if (!this.value || !Array.isArray(this.value)) {
|
||||
newValue = event.detail.value ? [event.detail.value] : undefined;
|
||||
} else {
|
||||
newValue = [...this.value];
|
||||
if (event.detail.value) {
|
||||
if (idx > this.value.length) {
|
||||
newValue.push(event.detail.value);
|
||||
} else {
|
||||
newValue[idx] = event.detail.value;
|
||||
}
|
||||
} else {
|
||||
newValue.splice(idx, 1);
|
||||
if (newValue.length === 0) {
|
||||
newValue = undefined;
|
||||
}
|
||||
willUpdate(changedProps: PropertyValues<this>) {
|
||||
if (changedProps.has("context")) {
|
||||
if (!this._hasAccept) {
|
||||
this._contextEntities = ensureArray(this.context?.filter_entity);
|
||||
}
|
||||
}
|
||||
fireEvent(this, "value-changed", { value: newValue });
|
||||
};
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (this.selector.media?.multiple) {
|
||||
const value = ensureArray(this.value);
|
||||
const result: TemplateResult[] = this.label
|
||||
? [html`<label>${this.label}</label>`]
|
||||
: [];
|
||||
if (value) {
|
||||
result.push(
|
||||
...value.map((v, i) =>
|
||||
this._renderPicker(undefined, v, i, this._multiValueChanged)
|
||||
)
|
||||
);
|
||||
}
|
||||
result.push(
|
||||
this._renderPicker(
|
||||
this.helper,
|
||||
undefined,
|
||||
value?.length || 0,
|
||||
this._multiValueChanged
|
||||
)
|
||||
);
|
||||
return result;
|
||||
const entityId = this._getActiveEntityId();
|
||||
|
||||
const stateObj = entityId ? this.hass.states[entityId] : undefined;
|
||||
|
||||
const supportsBrowse =
|
||||
!entityId ||
|
||||
(stateObj &&
|
||||
supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA));
|
||||
|
||||
if (this.selector.media?.image_upload && !this.value) {
|
||||
return html`${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-picture-upload
|
||||
.hass=${this.hass}
|
||||
.value=${null}
|
||||
.contentIdHelper=${this.selector.media?.content_id_helper}
|
||||
select-media
|
||||
full-media
|
||||
@media-picked=${this._pictureUploadMediaPicked}
|
||||
></ha-picture-upload>`;
|
||||
}
|
||||
|
||||
return this._renderPicker(
|
||||
this.helper,
|
||||
Array.isArray(this.value) ? this.value[0] : this.value
|
||||
return html`
|
||||
${
|
||||
this._hasAccept ||
|
||||
(this._contextEntities && this._contextEntities.length <= 1)
|
||||
? nothing
|
||||
: html`
|
||||
<ha-entity-picker
|
||||
.value=${entityId}
|
||||
.label=${
|
||||
this.label ||
|
||||
this.hass.localize(
|
||||
"ui.components.selectors.media.pick_media_player"
|
||||
)
|
||||
}
|
||||
.disabled=${this.disabled}
|
||||
.helper=${this.helper}
|
||||
.required=${this.required}
|
||||
.hideClearIcon=${!!this._contextEntities}
|
||||
.includeDomains=${INCLUDE_DOMAINS}
|
||||
.includeEntities=${this._contextEntities}
|
||||
.allowCustomEntity=${!this._contextEntities}
|
||||
@value-changed=${this._entityChanged}
|
||||
></ha-entity-picker>
|
||||
`
|
||||
}
|
||||
${
|
||||
!supportsBrowse
|
||||
? html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-alert>
|
||||
${this.hass.localize(
|
||||
"ui.components.selectors.media.browse_not_supported"
|
||||
)}
|
||||
</ha-alert>
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this.value || EMPTY_FORM}
|
||||
.schema=${MANUAL_SCHEMA}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.computeHelper=${this._computeHelperCallback}
|
||||
></ha-form>
|
||||
`
|
||||
: html`${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-card
|
||||
outlined
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label=${
|
||||
!this.value?.media_content_id
|
||||
? this.hass.localize(
|
||||
"ui.components.selectors.media.pick_media"
|
||||
)
|
||||
: this.value.metadata?.title || this.value.media_content_id
|
||||
}
|
||||
@click=${this._pickMedia}
|
||||
@keydown=${this._handleKeyDown}
|
||||
class=${
|
||||
this.disabled || (!entityId && !this._hasAccept)
|
||||
? "disabled"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<div class="content-container">
|
||||
<div class="thumbnail">
|
||||
${
|
||||
this.value?.metadata?.thumbnail
|
||||
? html`
|
||||
<div
|
||||
class="${classMap({
|
||||
"centered-image":
|
||||
!!this.value.metadata.media_class &&
|
||||
["app", "directory"].includes(
|
||||
this.value.metadata.media_class
|
||||
),
|
||||
})}
|
||||
image"
|
||||
>
|
||||
<ha-media-browser-thumbnail
|
||||
.hass=${this.hass}
|
||||
.url=${this.value.metadata.thumbnail}
|
||||
></ha-media-browser-thumbnail>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="icon-holder image">
|
||||
<ha-svg-icon
|
||||
class="folder"
|
||||
.path=${
|
||||
!this.value?.media_content_id
|
||||
? mdiPlus
|
||||
: this.value?.metadata?.media_class
|
||||
? MediaClassBrowserSettings[
|
||||
this.value.metadata.media_class ===
|
||||
"directory"
|
||||
? this.value.metadata
|
||||
.children_media_class ||
|
||||
this.value.metadata.media_class
|
||||
: this.value.metadata.media_class
|
||||
].icon
|
||||
: mdiPlayBox
|
||||
}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
<div class="title">
|
||||
${
|
||||
!this.value?.media_content_id
|
||||
? this.hass.localize(
|
||||
"ui.components.selectors.media.pick_media"
|
||||
)
|
||||
: this.value.metadata?.title ||
|
||||
this.value.media_content_id
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
${
|
||||
this.selector.media?.clearable
|
||||
? html`<div>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
variant="danger"
|
||||
@click=${this._clearValue}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.components.picture-upload.clear_picture"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>`
|
||||
: nothing
|
||||
}`
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<typeof MANUAL_SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(`ui.components.selectors.media.${schema.name}`);
|
||||
|
||||
private _computeHelperCallback = (
|
||||
schema: SchemaUnion<typeof MANUAL_SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(`ui.components.selectors.media.${schema.name}_detail`);
|
||||
|
||||
private _entityChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
if (!this._hasAccept && this.context?.filter_entity) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
media_content_id: "",
|
||||
media_content_type: "",
|
||||
metadata: {
|
||||
browse_entity_id: ev.detail.value,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
entity_id: ev.detail.value,
|
||||
media_content_id: "",
|
||||
media_content_type: "",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _pickMedia() {
|
||||
showMediaBrowserDialog(this, {
|
||||
action: "pick",
|
||||
entityId: this._getActiveEntityId(),
|
||||
navigateIds: this.value?.metadata?.navigateIds,
|
||||
accept: this.selector.media?.accept,
|
||||
defaultId: this.value?.media_content_id,
|
||||
defaultType: this.value?.media_content_type,
|
||||
hideContentType: this.selector.media?.hide_content_type,
|
||||
contentIdHelper: this.selector.media?.content_id_helper,
|
||||
mediaPickedCallback: (pickedMedia: MediaPickedEvent) => {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.value,
|
||||
media_content_id: pickedMedia.item.media_content_id,
|
||||
media_content_type: pickedMedia.item.media_content_type,
|
||||
metadata: {
|
||||
title: pickedMedia.item.title,
|
||||
thumbnail: pickedMedia.item.thumbnail,
|
||||
media_class: pickedMedia.item.media_class,
|
||||
children_media_class: pickedMedia.item.children_media_class,
|
||||
navigateIds: pickedMedia.navigateIds?.map((id) => ({
|
||||
media_content_type: id.media_content_type,
|
||||
media_content_id: id.media_content_id,
|
||||
})),
|
||||
...(!this._hasAccept && this.context?.filter_entity
|
||||
? { browse_entity_id: this._getActiveEntityId() }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _getActiveEntityId(): string | undefined {
|
||||
const metaId = this.value?.metadata?.browse_entity_id;
|
||||
return (
|
||||
this.value?.entity_id ||
|
||||
(metaId && this._contextEntities?.includes(metaId) && metaId) ||
|
||||
this._contextEntities?.[0]
|
||||
);
|
||||
}
|
||||
|
||||
private _handleKeyDown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
this._pickMedia();
|
||||
}
|
||||
}
|
||||
|
||||
private _pictureUploadMediaPicked(ev) {
|
||||
const pickedMedia = ev.detail as MediaPickedEvent;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.value,
|
||||
media_content_id: pickedMedia.item.media_content_id,
|
||||
media_content_type: pickedMedia.item.media_content_type,
|
||||
metadata: {
|
||||
title: pickedMedia.item.title,
|
||||
thumbnail: pickedMedia.item.thumbnail,
|
||||
media_class: pickedMedia.item.media_class,
|
||||
children_media_class: pickedMedia.item.children_media_class,
|
||||
navigateIds: pickedMedia.navigateIds?.map((id) => ({
|
||||
media_content_type: id.media_content_type,
|
||||
media_content_id: id.media_content_id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _clearValue() {
|
||||
fireEvent(this, "value-changed", { value: undefined });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-entity-picker {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
ha-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
transition: background-color 180ms ease-in-out;
|
||||
min-height: 56px;
|
||||
}
|
||||
ha-card:hover:not(.disabled),
|
||||
ha-card:focus:not(.disabled) {
|
||||
background-color: var(--state-icon-hover-color, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
ha-card:focus {
|
||||
outline: none;
|
||||
}
|
||||
ha-card.disabled {
|
||||
pointer-events: none;
|
||||
color: var(--disabled-text-color);
|
||||
}
|
||||
.content-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
ha-card .thumbnail {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
ha-card .image {
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
.folder {
|
||||
--mdc-icon-size: 24px;
|
||||
}
|
||||
.title {
|
||||
font-size: var(--ha-font-size-m);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
--ha-media-browser-thumbnail-fit: cover;
|
||||
}
|
||||
.centered-image {
|
||||
margin: 4px;
|
||||
--ha-media-browser-thumbnail-fit: contain;
|
||||
}
|
||||
.icon-holder {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -67,16 +67,15 @@ export class HaNumberSelector extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
// On iOS/iPadOS the numeric and decimal on-screen keypads have no minus key.
|
||||
// Leaving inputmode unset on a number input gives the "Numbers and
|
||||
// Punctuation" keyboard there, which does include a minus. Other platforms
|
||||
// include a minus on their number keypads, so restrict this workaround to
|
||||
// Safari/WebKit and only when the selector allows negatives: either an
|
||||
// explicit negative min, or no min at all (e.g. the numeric threshold
|
||||
// selector used by the power triggers).
|
||||
const useSafariNegativeKeyboard =
|
||||
// On iOS/iPadOS the numeric and decimal on-screen keypads have no minus key,
|
||||
// so negatives can only be typed with the full "text" keyboard. Other
|
||||
// platforms include a minus on their number keypads, so restrict this
|
||||
// workaround to Safari/WebKit and only when the selector allows negatives
|
||||
// (e.g. numeric_state triggers/conditions).
|
||||
const useTextInputMode =
|
||||
isSafari &&
|
||||
(this.selector.number?.min === undefined || this.selector.number.min < 0);
|
||||
this.selector.number?.min !== undefined &&
|
||||
this.selector.number.min < 0;
|
||||
|
||||
const translationKey = this.selector.number?.translation_key;
|
||||
let unit = this.selector.number?.unit_of_measurement;
|
||||
@@ -113,8 +112,8 @@ export class HaNumberSelector extends LitElement {
|
||||
}
|
||||
<ha-input
|
||||
.inputmode=${
|
||||
useSafariNegativeKeyboard
|
||||
? undefined
|
||||
useTextInputMode
|
||||
? "text"
|
||||
: this.selector.number?.step === "any" ||
|
||||
(this.selector.number?.step ?? 1) % 1 !== 0
|
||||
? "decimal"
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { ObjectSelector } from "../../data/selector";
|
||||
import { formatSelectorValue } from "../../data/selector/format_selector_value";
|
||||
import { showFormDialog } from "../../dialogs/form/show-form-dialog";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { computeInitialHaFormData } from "../ha-form/compute-initial-ha-form-data";
|
||||
import type { HaFormSchema } from "../ha-form/types";
|
||||
import "../ha-input-helper-text";
|
||||
import "../ha-md-list";
|
||||
@@ -238,14 +237,10 @@ export class HaObjectSelector extends LitElement {
|
||||
private async _addItem(ev) {
|
||||
ev.stopPropagation();
|
||||
|
||||
const schema = this._schema(this.selector);
|
||||
|
||||
const newItem = await showFormDialog(this, {
|
||||
title: this.hass.localize("ui.common.add"),
|
||||
schema,
|
||||
data: computeInitialHaFormData(schema, {
|
||||
skipUnsupportedSelectors: true,
|
||||
}),
|
||||
schema: this._schema(this.selector),
|
||||
data: {},
|
||||
computeLabel: this._computeLabel,
|
||||
computeHelper: this._computeHelper,
|
||||
submitText: this.hass.localize("ui.common.add"),
|
||||
@@ -298,7 +293,7 @@ export class HaObjectSelector extends LitElement {
|
||||
const index = ev.currentTarget.index;
|
||||
|
||||
if (!this.selector.object!.multiple) {
|
||||
fireEvent(this, "value-changed", { value: "" });
|
||||
fireEvent(this, "value-changed", { value: undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -99,10 +99,6 @@ const SELECTOR_SCHEMAS = {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple",
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
] as const,
|
||||
number: [
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ import { html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
|
||||
import type { Selector, SelectorType } from "../../data/selector";
|
||||
import type { Selector } from "../../data/selector";
|
||||
import {
|
||||
handleLegacyDeviceSelector,
|
||||
handleLegacyEntitySelector,
|
||||
@@ -30,7 +30,6 @@ 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"),
|
||||
@@ -70,7 +69,7 @@ const LOAD_ELEMENTS = {
|
||||
ui_color: () => import("./ha-selector-ui-color"),
|
||||
ui_state_content: () => import("./ha-selector-ui-state-content"),
|
||||
ui_time_format: () => import("./ha-selector-ui-time-format"),
|
||||
} satisfies Record<SelectorType, () => Promise<unknown>>;
|
||||
};
|
||||
|
||||
const LEGACY_UI_SELECTORS = new Set(["ui-action", "ui-color"]);
|
||||
|
||||
|
||||
@@ -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-entity-types";
|
||||
import "./ha-filter-device-classes";
|
||||
import "./ha-filter-domains";
|
||||
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 (`sensor`) and domains narrowed to a device class (`sensor/power`). */
|
||||
types?: string[];
|
||||
domains?: string[];
|
||||
deviceClasses?: string[];
|
||||
integrations?: string[];
|
||||
}
|
||||
|
||||
@@ -44,30 +44,38 @@ export const countSourceFilters = (filters: SourceFilters): number =>
|
||||
Object.values(filters).filter((value) => value?.length).length;
|
||||
|
||||
/**
|
||||
* Matches an entity against the selected filters: it is kept when it matches
|
||||
* every filter that has a selection. Undefined when nothing is selected.
|
||||
* Narrows entity IDs down by the selected filters: an entity is kept when it
|
||||
* matches every filter that has a selection.
|
||||
*/
|
||||
export const sourceFilterFunc = (
|
||||
export const applySourceFilters = (
|
||||
entityIds: string[],
|
||||
filters: SourceFilters,
|
||||
states: HomeAssistant["states"],
|
||||
entities: HomeAssistant["entities"],
|
||||
entitySources?: EntitySources
|
||||
): ((entityId: string) => boolean) | undefined => {
|
||||
const matchesType = filters.types?.length
|
||||
? entityTypeFilterFunc(filters.types, states)
|
||||
): string[] => {
|
||||
const domains = filters.domains?.length ? filters.domains : undefined;
|
||||
const deviceClasses = filters.deviceClasses?.length
|
||||
? filters.deviceClasses
|
||||
: undefined;
|
||||
const integrations = filters.integrations?.length
|
||||
? filters.integrations
|
||||
: undefined;
|
||||
|
||||
if (!matchesType && !integrations) {
|
||||
return undefined;
|
||||
if (!domains && !deviceClasses && !integrations) {
|
||||
return entityIds;
|
||||
}
|
||||
|
||||
return (entityId: string) => {
|
||||
if (matchesType && !matchesType(entityId)) {
|
||||
return entityIds.filter((entityId) => {
|
||||
if (domains && !domains.includes(computeDomain(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;
|
||||
@@ -76,24 +84,13 @@ export const sourceFilterFunc = (
|
||||
}
|
||||
}
|
||||
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
|
||||
* entity type and integration. Meant to be placed in an `ha-filter-pane`.
|
||||
* domain, device class 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.
|
||||
@@ -109,8 +106,6 @@ 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;
|
||||
|
||||
@@ -134,12 +129,6 @@ 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}
|
||||
@@ -147,12 +136,18 @@ export class HaSourcesPicker extends LitElement {
|
||||
<div
|
||||
class=${classMap({ filters: true, expanded: !!this._expandedFilter })}
|
||||
>
|
||||
<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-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-integrations
|
||||
.value=${this.filters.integrations}
|
||||
.expanded=${this._expandedFilter === "integrations"}
|
||||
@@ -163,8 +158,6 @@ 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");
|
||||
@@ -175,8 +168,12 @@ export class HaSourcesPicker extends LitElement {
|
||||
fireEvent(this, "value-changed", { value: ev.detail.value || {} });
|
||||
}
|
||||
|
||||
private _typesChanged(ev: CustomEvent) {
|
||||
this._filterChanged("types", ev);
|
||||
private _domainsChanged(ev: CustomEvent) {
|
||||
this._filterChanged("domains", ev);
|
||||
}
|
||||
|
||||
private _deviceClassesChanged(ev: CustomEvent) {
|
||||
this._filterChanged("deviceClasses", ev);
|
||||
}
|
||||
|
||||
private _integrationsChanged(ev: CustomEvent) {
|
||||
@@ -194,8 +191,12 @@ export class HaSourcesPicker extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _typesExpanded(ev: CustomEvent) {
|
||||
this._filterExpanded("types", ev);
|
||||
private _domainsExpanded(ev: CustomEvent) {
|
||||
this._filterExpanded("domains", ev);
|
||||
}
|
||||
|
||||
private _deviceClassesExpanded(ev: CustomEvent) {
|
||||
this._filterExpanded("deviceClasses", ev);
|
||||
}
|
||||
|
||||
private _integrationsExpanded(ev: CustomEvent) {
|
||||
|
||||
@@ -108,13 +108,6 @@ 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;
|
||||
@@ -293,7 +286,6 @@ 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}
|
||||
@@ -314,7 +306,6 @@ 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}
|
||||
@@ -338,7 +329,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
}}
|
||||
.deviceFilter=${this.deviceFilter}
|
||||
.entityFilter=${this.entityFilter}
|
||||
.activeFilter=${this.activeFilter}
|
||||
.includeDomains=${this.includeDomains}
|
||||
.includeDeviceClasses=${this.includeDeviceClasses}
|
||||
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
|
||||
@@ -358,7 +348,6 @@ 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}
|
||||
|
||||
+107
-135
@@ -9,8 +9,7 @@ import {
|
||||
} from "../resources/theme/color/color.globals";
|
||||
import type { HomeAssistant, ThemeSettings, ValueChangedEvent } from "../types";
|
||||
import "./ha-button";
|
||||
import "./item/ha-list-item-base";
|
||||
import "./list/ha-list-base";
|
||||
import "./ha-settings-row";
|
||||
import "./ha-theme-picker";
|
||||
import "./input/ha-input";
|
||||
import "./radio/ha-radio-group";
|
||||
@@ -26,7 +25,6 @@ export interface ThemeSettingsLabels {
|
||||
autoMode?: string;
|
||||
lightMode?: string;
|
||||
darkMode?: string;
|
||||
colors?: string;
|
||||
primaryColor?: string;
|
||||
accentColor?: string;
|
||||
reset?: string;
|
||||
@@ -44,7 +42,7 @@ export class HaThemeSettings extends LitElement {
|
||||
|
||||
@property() public heading?: string;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ attribute: "include-default", type: Boolean })
|
||||
public includeDefault = false;
|
||||
@@ -64,121 +62,98 @@ export class HaThemeSettings extends LitElement {
|
||||
? this.hass.themes.default_dark_theme || this.hass.themes.default_theme
|
||||
: this.hass.themes.default_theme;
|
||||
|
||||
const showDarkMode =
|
||||
curTheme === HOME_ASSISTANT_THEME ||
|
||||
(curThemeIsUseDefault &&
|
||||
this.hass.themes.default_dark_theme &&
|
||||
this.hass.themes.default_theme) ||
|
||||
this._supportsModeSelection(curTheme);
|
||||
|
||||
return html`
|
||||
<ha-list-base>
|
||||
<ha-settings-row .narrow=${this.narrow} ?empty=${!this.showThemePicker}>
|
||||
${
|
||||
this.heading
|
||||
? html`<span slot="heading">${this.heading}</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.description
|
||||
? html`<span slot="description">${this.description}</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.showThemePicker
|
||||
? html`
|
||||
<ha-list-item-base>
|
||||
${
|
||||
this.heading
|
||||
? html`<span slot="headline">${this.heading}</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.description
|
||||
? html`<span slot="supporting-text"
|
||||
>${this.description}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
<ha-theme-picker
|
||||
slot="end"
|
||||
.label=${this.labels?.theme}
|
||||
.noThemeLabel=${this.labels?.noTheme}
|
||||
.value=${themeSettings?.theme || undefined}
|
||||
.disabled=${this.themePickerDisabled}
|
||||
?include-default=${this.includeDefault}
|
||||
@value-changed=${this._handleThemeSelection}
|
||||
></ha-theme-picker>
|
||||
</ha-list-item-base>
|
||||
<ha-theme-picker
|
||||
.label=${this.labels?.theme}
|
||||
.noThemeLabel=${this.labels?.noTheme}
|
||||
.value=${themeSettings?.theme || undefined}
|
||||
.disabled=${this.themePickerDisabled}
|
||||
?include-default=${this.includeDefault}
|
||||
@value-changed=${this._handleThemeSelection}
|
||||
></ha-theme-picker>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
showDarkMode
|
||||
? html`
|
||||
<ha-list-item-base>
|
||||
<span slot="headline"
|
||||
>${this.labels?.mode ?? "Theme mode"}</span
|
||||
>
|
||||
<ha-radio-group
|
||||
slot="end"
|
||||
@change=${this._handleDarkMode}
|
||||
name="dark_mode"
|
||||
.value=${
|
||||
themeSettings?.dark === undefined
|
||||
? "auto"
|
||||
: themeSettings.dark
|
||||
? "dark"
|
||||
: "light"
|
||||
}
|
||||
orientation="horizontal"
|
||||
>
|
||||
<ha-radio-option value="auto">
|
||||
${this.labels?.autoMode ?? "Auto"}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="light">
|
||||
${this.labels?.lightMode ?? "Light"}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="dark">
|
||||
${this.labels?.darkMode ?? "Dark"}
|
||||
</ha-radio-option>
|
||||
</ha-radio-group>
|
||||
</ha-list-item-base>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
curTheme === HOME_ASSISTANT_THEME
|
||||
? html`
|
||||
<ha-list-item-base>
|
||||
<span slot="headline"
|
||||
>${this.labels?.colors ?? "Custom colors"}</span
|
||||
>
|
||||
<div slot="end" class="color-pickers">
|
||||
<ha-input
|
||||
.value=${themeSettings?.primaryColor || DefaultPrimaryColor}
|
||||
type="color"
|
||||
.label=${this.labels?.primaryColor ?? "Primary color"}
|
||||
.name=${"primaryColor"}
|
||||
@change=${this._handleColorChange}
|
||||
></ha-input>
|
||||
<ha-input
|
||||
.value=${themeSettings?.accentColor || DefaultAccentColor}
|
||||
type="color"
|
||||
.label=${this.labels?.accentColor ?? "Accent color"}
|
||||
.name=${"accentColor"}
|
||||
@change=${this._handleColorChange}
|
||||
></ha-input>
|
||||
</div>
|
||||
</ha-list-item-base>
|
||||
${
|
||||
themeSettings?.primaryColor || themeSettings?.accentColor
|
||||
? html`
|
||||
<div class="reset-row">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
@click=${this._resetColors}
|
||||
>
|
||||
${this.labels?.reset ?? "Reset"}
|
||||
</ha-button>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
</ha-settings-row>
|
||||
${
|
||||
curTheme === HOME_ASSISTANT_THEME ||
|
||||
(curThemeIsUseDefault &&
|
||||
this.hass.themes.default_dark_theme &&
|
||||
this.hass.themes.default_theme) ||
|
||||
this._supportsModeSelection(curTheme)
|
||||
? html`<div class="inputs">
|
||||
<ha-radio-group
|
||||
@change=${this._handleDarkMode}
|
||||
name="dark_mode"
|
||||
.ariaLabel=${this.labels?.mode ?? "Theme mode"}
|
||||
.value=${
|
||||
themeSettings?.dark === undefined
|
||||
? "auto"
|
||||
: themeSettings.dark
|
||||
? "dark"
|
||||
: "light"
|
||||
}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-list-base>
|
||||
orientation="horizontal"
|
||||
>
|
||||
<ha-radio-option value="auto">
|
||||
${this.labels?.autoMode ?? "Auto"}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="light">
|
||||
${this.labels?.lightMode ?? "Light"}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="dark">
|
||||
${this.labels?.darkMode ?? "Dark"}
|
||||
</ha-radio-option>
|
||||
</ha-radio-group>
|
||||
${
|
||||
curTheme === HOME_ASSISTANT_THEME
|
||||
? html`<div class="color-pickers">
|
||||
<ha-input
|
||||
.value=${themeSettings?.primaryColor || DefaultPrimaryColor}
|
||||
type="color"
|
||||
.label=${this.labels?.primaryColor ?? "Primary color"}
|
||||
.name=${"primaryColor"}
|
||||
@change=${this._handleColorChange}
|
||||
></ha-input>
|
||||
<ha-input
|
||||
.value=${themeSettings?.accentColor || DefaultAccentColor}
|
||||
type="color"
|
||||
.label=${this.labels?.accentColor ?? "Accent color"}
|
||||
.name=${"accentColor"}
|
||||
@change=${this._handleColorChange}
|
||||
></ha-input>
|
||||
${
|
||||
themeSettings?.primaryColor ||
|
||||
themeSettings?.accentColor
|
||||
? html` <ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
@click=${this._resetColors}
|
||||
>
|
||||
${this.labels?.reset ?? "Reset"}
|
||||
</ha-button>`
|
||||
: nothing
|
||||
}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -256,35 +231,32 @@ export class HaThemeSettings extends LitElement {
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
ha-list-base {
|
||||
--ha-row-item-padding-block: var(--ha-space-2);
|
||||
.inputs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
margin: 0 var(--ha-space-3);
|
||||
}
|
||||
ha-theme-picker {
|
||||
min-width: 150px;
|
||||
ha-radio-group {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-inline-end: var(--ha-space-3);
|
||||
}
|
||||
.color-pickers {
|
||||
display: flex;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.color-pickers ha-input {
|
||||
min-width: 150px;
|
||||
flex: 1;
|
||||
}
|
||||
:host([narrow]) ha-list-item-base::part(base) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
:host([narrow]) ha-list-item-base::part(end) {
|
||||
width: 100%;
|
||||
}
|
||||
:host([narrow]) .color-pickers {
|
||||
width: 100%;
|
||||
}
|
||||
.reset-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-inline-end: var(--ha-space-4);
|
||||
padding-bottom: var(--ha-space-4);
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
ha-input {
|
||||
min-width: 75px;
|
||||
flex-grow: 1;
|
||||
margin: 0 var(--ha-space-1);
|
||||
}
|
||||
|
||||
ha-theme-picker {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import { transform } from "../../common/decorators/transform";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
|
||||
import { setupLeafletMap } from "../../common/dom/setup-leaflet-map";
|
||||
import type { MapBaseLayer } from "../../common/map/base-layer";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { getEntityLocation } from "../../common/entity/get_entity_location";
|
||||
@@ -161,8 +160,6 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
private Leaflet?: LeafletModuleType;
|
||||
|
||||
private _baseLayer?: MapBaseLayer;
|
||||
|
||||
private _resizeObserver?: ResizeObserver;
|
||||
|
||||
private _mapItems: (Marker | Circle)[] = [];
|
||||
@@ -214,7 +211,6 @@ export class HaMap extends ReactiveElement {
|
||||
this.leafletMap.remove();
|
||||
this.leafletMap = undefined;
|
||||
this.Leaflet = undefined;
|
||||
this._baseLayer = undefined;
|
||||
}
|
||||
|
||||
// the control went away with the map, so don't hold on to it
|
||||
@@ -312,7 +308,6 @@ export class HaMap extends ReactiveElement {
|
||||
map.classList.toggle("dark", this._darkMode);
|
||||
map.classList.toggle("forced-dark", this.themeMode === "dark");
|
||||
map.classList.toggle("forced-light", this.themeMode === "light");
|
||||
this._baseLayer?.setDarkMode(this._darkMode);
|
||||
}
|
||||
|
||||
private _loading = false;
|
||||
@@ -327,23 +322,11 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
this._loading = true;
|
||||
try {
|
||||
const setup = await setupLeafletMap(map, {
|
||||
[this.leafletMap, this.Leaflet] = await setupLeafletMap(map, {
|
||||
latitude: this._config?.latitude ?? 52.3731339,
|
||||
longitude: this._config?.longitude ?? 4.8903147,
|
||||
zoom: this.zoom,
|
||||
darkMode: this._darkMode,
|
||||
});
|
||||
// Setting up fetches a style, so the element can be gone by now.
|
||||
// `disconnectedCallback` had no map to tear down, and keeping this one
|
||||
// would leave a live map - and its WebGL context - on a detached host,
|
||||
// and its container too initialized to set up again on reconnect.
|
||||
if (!this.isConnected) {
|
||||
setup.map.remove();
|
||||
return;
|
||||
}
|
||||
this.leafletMap = setup.map;
|
||||
this.Leaflet = setup.leaflet;
|
||||
this._baseLayer = setup.baseLayer;
|
||||
this._updateMapStyle();
|
||||
this.leafletMap.on("click", (ev) => {
|
||||
if (this._clickCount === 0) {
|
||||
@@ -908,22 +891,9 @@ export class HaMap extends ReactiveElement {
|
||||
cursor: -moz-grabbing;
|
||||
cursor: -webkit-grabbing;
|
||||
}
|
||||
/* Only the raster fallback is inverted for dark mode, the vector style
|
||||
ships its own dark cartography. */
|
||||
.leaflet-tile-pane .leaflet-tile {
|
||||
.leaflet-tile-pane {
|
||||
filter: var(--map-filter);
|
||||
}
|
||||
/* The only two rules the MapLibre canvas needs from its stylesheet, the
|
||||
rest of it styles controls and popups we do not render. */
|
||||
.maplibregl-map {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.maplibregl-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.dark .leaflet-bar a {
|
||||
background-color: #1c1c1c;
|
||||
color: #ffffff;
|
||||
|
||||
@@ -470,19 +470,15 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
? MediaClassBrowserSettings[currentItem.children_media_class]
|
||||
: MediaClassBrowserSettings.directory;
|
||||
|
||||
const canPickCurrent =
|
||||
currentItem?.can_play ||
|
||||
(currentItem && this.accept?.includes("directory"));
|
||||
|
||||
return html`
|
||||
${
|
||||
canPickCurrent || showSearch
|
||||
currentItem.can_play || showSearch
|
||||
? html`
|
||||
<div
|
||||
class="header ${classMap({
|
||||
"no-img": !currentItem.thumbnail,
|
||||
"no-dialog": !this.dialog,
|
||||
"search-only": !canPickCurrent,
|
||||
"search-only": !currentItem.can_play,
|
||||
})}"
|
||||
@transitionend=${this._setHeaderHeight}
|
||||
>
|
||||
@@ -495,7 +491,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
canPickCurrent
|
||||
currentItem.can_play
|
||||
? html`<div class="header-content">
|
||||
${
|
||||
currentItem.thumbnail
|
||||
@@ -507,7 +503,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
></ha-media-browser-thumbnail>
|
||||
${
|
||||
this.narrow &&
|
||||
canPickCurrent &&
|
||||
currentItem?.can_play &&
|
||||
(!this.accept ||
|
||||
canPlayChildren.has(
|
||||
currentItem.media_content_id
|
||||
@@ -550,7 +546,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
}
|
||||
</div>
|
||||
${
|
||||
canPickCurrent &&
|
||||
currentItem.can_play &&
|
||||
(!currentItem.thumbnail || !this.narrow)
|
||||
? html`
|
||||
<ha-button
|
||||
|
||||
@@ -129,21 +129,31 @@ export class HaMediaPlayerPicker extends LitElement {
|
||||
.filter(this._filterPlayerEntities)
|
||||
.map<MediaPlayerComboBoxItem>((stateObj) => {
|
||||
const friendlyName = computeStateName(stateObj);
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this._entities,
|
||||
this._devices,
|
||||
this._areas,
|
||||
this._floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this._entities,
|
||||
this._devices,
|
||||
this._areas,
|
||||
this._floors
|
||||
);
|
||||
const entityId = stateObj.entity_id;
|
||||
const domainName = domainToName(
|
||||
this._i18n.localize,
|
||||
computeDomain(entityId)
|
||||
);
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
@@ -157,6 +167,7 @@ export class HaMediaPlayerPicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
domainName: domainName || null,
|
||||
friendlyName: friendlyName || null,
|
||||
|
||||
@@ -62,17 +62,27 @@ class HaMediaPlayerToggle extends LitElement {
|
||||
isRTL: boolean,
|
||||
stateObj: HassEntity
|
||||
) => {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
|
||||
@@ -118,16 +118,6 @@ 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;
|
||||
}
|
||||
@@ -137,8 +127,6 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const { activeFilter } = this._params;
|
||||
|
||||
let deviceFilter: HaDevicePickerDeviceFilterFunc | undefined;
|
||||
let entityFilter: HaEntityPickerEntityFilterFunc | undefined;
|
||||
let includeDomains: string[] | undefined;
|
||||
@@ -157,10 +145,6 @@ 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,7 +11,6 @@ export interface TargetDetailsDialogParams {
|
||||
selector?: TargetSelector;
|
||||
deviceFilter?: HaDevicePickerDeviceFilterFunc;
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
activeFilter?: (entityId: string) => boolean;
|
||||
includeDomains?: string[];
|
||||
includeDeviceClasses?: string[];
|
||||
primaryEntitiesOnly?: boolean;
|
||||
|
||||
@@ -34,9 +34,6 @@ 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}
|
||||
@@ -91,7 +88,6 @@ 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}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
mdiHome,
|
||||
mdiLabel,
|
||||
mdiMinusBox,
|
||||
mdiSwapHorizontal,
|
||||
mdiTextureBox,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
@@ -93,13 +92,6 @@ 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}
|
||||
@@ -178,51 +170,36 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
referrerpolicy="no-referrer"
|
||||
src=${this._iconImg}
|
||||
/>`
|
||||
: canMigrate
|
||||
? html`<ha-svg-icon .path=${mdiSwapHorizontal}></ha-svg-icon>`
|
||||
: fallbackIconPath
|
||||
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
|
||||
: this.type === "entity"
|
||||
? html`
|
||||
<ha-state-icon
|
||||
.stateObj=${
|
||||
stateObject ||
|
||||
({
|
||||
entity_id: this.itemId,
|
||||
attributes: {},
|
||||
} as HassEntity)
|
||||
}
|
||||
>
|
||||
</ha-state-icon>
|
||||
`
|
||||
: nothing
|
||||
: fallbackIconPath
|
||||
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
|
||||
: this.type === "entity"
|
||||
? html`
|
||||
<ha-state-icon
|
||||
.stateObj=${
|
||||
stateObject ||
|
||||
({
|
||||
entity_id: this.itemId,
|
||||
attributes: {},
|
||||
} as HassEntity)
|
||||
}
|
||||
>
|
||||
</ha-state-icon>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
|
||||
<span slot="headline"
|
||||
>${
|
||||
canMigrate
|
||||
? this.hass.localize(
|
||||
"ui.components.target-picker.device_replaced_headline"
|
||||
)
|
||||
: name
|
||||
}</span
|
||||
>
|
||||
<div slot="headline">${(canMigrate && replacement?.name) || name}</div>
|
||||
${
|
||||
notFound || (context && !this.hideContext)
|
||||
? html`<span slot="supporting-text"
|
||||
>${
|
||||
notFound
|
||||
? canMigrate
|
||||
? replacement!.candidates.length === 1 && replacement!.name
|
||||
? this.hass.localize(
|
||||
"ui.components.target-picker.device_replaced_by_one",
|
||||
{ device: replacement!.name }
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.components.target-picker.device_replaced",
|
||||
{ count: replacement!.candidates.length }
|
||||
)
|
||||
? this.hass.localize(
|
||||
"ui.components.target-picker.device_replaced",
|
||||
{ count: replacement!.candidates.length }
|
||||
)
|
||||
: this.hass.localize(
|
||||
`ui.components.target-picker.${this.type}_not_found`
|
||||
)
|
||||
@@ -245,7 +222,12 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
${
|
||||
this.expand || !entries.referenced_entities.length
|
||||
? html`<span class="main">
|
||||
${this._entitiesLabel(entries)}
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.entities_count",
|
||||
{
|
||||
count: entries.referenced_entities.length,
|
||||
}
|
||||
)}
|
||||
</span>`
|
||||
: html`<ha-button
|
||||
appearance="filled"
|
||||
@@ -253,7 +235,12 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
size="xs"
|
||||
@click=${this._openDetails}
|
||||
>
|
||||
${this._entitiesLabel(entries)}
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.entities_count",
|
||||
{
|
||||
count: entries.referenced_entities.length,
|
||||
}
|
||||
)}
|
||||
</ha-button>`
|
||||
}
|
||||
</div>
|
||||
@@ -272,7 +259,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
@click=${this._migrate}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.replace_update"
|
||||
"ui.components.target-picker.replace_device"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
@@ -347,28 +334,6 @@ 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;
|
||||
|
||||
@@ -564,73 +529,75 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
this.primaryEntitiesOnly
|
||||
);
|
||||
|
||||
let referencedAreas = entries.referenced_areas;
|
||||
const hiddenAreaIds: string[] = [];
|
||||
if (this.type === "floor" || this.type === "label") {
|
||||
referencedAreas = referencedAreas.filter((area_id) => {
|
||||
const area = this.hass.areas[area_id];
|
||||
// Absent from the registry is not a filter decision: drop the id
|
||||
// without marking it hidden, so entities targeted through their
|
||||
// own area or label are not dropped along with it.
|
||||
if (!area) {
|
||||
entries.referenced_areas = entries.referenced_areas.filter(
|
||||
(area_id) => {
|
||||
const area = this.hass.areas[area_id];
|
||||
// Absent from the registry is not a filter decision: drop the id
|
||||
// without marking it hidden, so entities targeted through their
|
||||
// own area or label are not dropped along with it.
|
||||
if (!area) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(this.type === "floor" || area.labels.includes(this.itemId)) &&
|
||||
areaMeetsFilter(
|
||||
area,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
hiddenAreaIds.push(area_id);
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(this.type === "floor" || area.labels.includes(this.itemId)) &&
|
||||
areaMeetsFilter(
|
||||
area,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
hiddenAreaIds.push(area_id);
|
||||
return false;
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
let referencedDevices = entries.referenced_devices;
|
||||
const hiddenDeviceIds: string[] = [];
|
||||
if (
|
||||
this.type === "floor" ||
|
||||
this.type === "area" ||
|
||||
this.type === "label"
|
||||
) {
|
||||
referencedDevices = referencedDevices.filter((device_id) => {
|
||||
const device = this.hass.devices[device_id];
|
||||
if (!device) {
|
||||
entries.referenced_devices = entries.referenced_devices.filter(
|
||||
(device_id) => {
|
||||
const device = this.hass.devices[device_id];
|
||||
if (!device) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!hiddenAreaIds.includes(device.area_id || "") &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
hiddenDeviceIds.push(device_id);
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!hiddenAreaIds.includes(device.area_id || "") &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
hiddenDeviceIds.push(device_id);
|
||||
return false;
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
const referencedEntities = entries.referenced_entities.filter(
|
||||
entries.referenced_entities = entries.referenced_entities.filter(
|
||||
(entity_id) => {
|
||||
const entity = this.hass.entities[entity_id];
|
||||
// Core can reference entities that are absent from the display
|
||||
@@ -645,9 +612,9 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
(this.type === "area" && entity.area_id === this.itemId) ||
|
||||
(this.type === "floor" &&
|
||||
entity.area_id &&
|
||||
referencedAreas.includes(entity.area_id)) ||
|
||||
entries.referenced_areas.includes(entity.area_id)) ||
|
||||
(this.type === "label" && entity.labels.includes(this.itemId)) ||
|
||||
referencedDevices.includes(entity.device_id || "")
|
||||
entries.referenced_devices.includes(entity.device_id || "")
|
||||
) {
|
||||
return entityRegMeetsFilter(
|
||||
entity,
|
||||
@@ -662,12 +629,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
this._entries = {
|
||||
...entries,
|
||||
referenced_areas: referencedAreas,
|
||||
referenced_devices: referencedDevices,
|
||||
referenced_entities: referencedEntities,
|
||||
};
|
||||
this._entries = entries;
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to extract target", e);
|
||||
@@ -721,7 +683,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
const entityName = stateObject
|
||||
? computeEntityName(stateObject, this.hass.entities, this.hass.devices)
|
||||
: item;
|
||||
const { area, device } = stateObject
|
||||
const { area, device, parentDevice } = stateObject
|
||||
? getEntityContext(
|
||||
stateObject,
|
||||
this.hass.entities,
|
||||
@@ -729,10 +691,17 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
)
|
||||
: { area: undefined, device: undefined };
|
||||
: { area: undefined, device: undefined, parentDevice: undefined };
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const parentDeviceName = parentDevice
|
||||
? computeDeviceName(parentDevice)
|
||||
: undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
const context = [areaName, entityName ? deviceName : undefined]
|
||||
const context = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(
|
||||
computeRTL(
|
||||
@@ -854,7 +823,6 @@ 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,5 +1,4 @@
|
||||
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";
|
||||
@@ -8,16 +7,8 @@ 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 type { ConditionDescriptions } from "../../data/condition";
|
||||
import {
|
||||
conditionDescriptionsContext,
|
||||
fullEntitiesContext,
|
||||
labelsContext,
|
||||
manifestsContext,
|
||||
triggerDescriptionsContext,
|
||||
} from "../../data/context";
|
||||
import { fullEntitiesContext, labelsContext } 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";
|
||||
@@ -26,12 +17,7 @@ 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";
|
||||
@@ -81,18 +67,6 @@ 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">
|
||||
@@ -217,8 +191,51 @@ export class HaTracePathDetails extends LitElement {
|
||||
)}`;
|
||||
}
|
||||
|
||||
const selectedType = this.selected.type;
|
||||
|
||||
return html`
|
||||
${this._renderStepHeading(curPath, currentDetail, pathParts)}
|
||||
${
|
||||
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>`
|
||||
}
|
||||
${
|
||||
data.length === 1
|
||||
? nothing
|
||||
@@ -229,7 +246,17 @@ export class HaTracePathDetails extends LitElement {
|
||||
)}
|
||||
</h3>`
|
||||
}
|
||||
${this._renderNestedCondition(curPath, currentDetail)}
|
||||
${
|
||||
curPath
|
||||
.substring(this.selected.path.length + 1)
|
||||
.includes("condition")
|
||||
? html`[${describeCondition(
|
||||
currentDetail,
|
||||
this.hass,
|
||||
this._entityReg
|
||||
)}]<br />`
|
||||
: nothing
|
||||
}
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.automation.trace.path.executed",
|
||||
{
|
||||
@@ -297,130 +324,6 @@ 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;
|
||||
@@ -560,42 +463,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -480,40 +480,6 @@ export const migrateAutomationConfig = <
|
||||
return config;
|
||||
};
|
||||
|
||||
// The fields of the row holding a trigger, condition or action, as opposed to
|
||||
// the configuration of its type. The UI editors of some types build their value
|
||||
// from scratch, so these have to be carried over explicitly.
|
||||
export const TRIGGER_ROW_CONFIG_KEYS = [
|
||||
"alias",
|
||||
"note",
|
||||
"id",
|
||||
"enabled",
|
||||
"variables",
|
||||
] as const;
|
||||
|
||||
export const CONDITION_ROW_CONFIG_KEYS = ["alias", "note", "enabled"] as const;
|
||||
|
||||
export const ACTION_ROW_CONFIG_KEYS = [
|
||||
"alias",
|
||||
"note",
|
||||
"enabled",
|
||||
"continue_on_error",
|
||||
] as const;
|
||||
|
||||
export const pickRowConfig = <T extends object>(
|
||||
row: T,
|
||||
keys: readonly string[]
|
||||
): Partial<T> => {
|
||||
const source = row as Record<string, unknown>;
|
||||
const config: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
if (key in source) {
|
||||
config[key] = source[key];
|
||||
}
|
||||
}
|
||||
return config as Partial<T>;
|
||||
};
|
||||
|
||||
export const migrateAutomationTrigger = (
|
||||
trigger: Trigger | Trigger[],
|
||||
report?: AutomationMigrationReport
|
||||
|
||||
+221
-33
@@ -149,6 +149,9 @@ 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 = (
|
||||
@@ -207,7 +210,8 @@ const tryDescribeTrigger = (
|
||||
const description = describeLegacyTrigger(
|
||||
trigger as LegacyTrigger,
|
||||
hass,
|
||||
entityRegistry
|
||||
entityRegistry,
|
||||
options?.hideEntities
|
||||
);
|
||||
|
||||
if (description) {
|
||||
@@ -231,7 +235,8 @@ const tryDescribeTrigger = (
|
||||
const describeLegacyTrigger = (
|
||||
trigger: LegacyTrigger,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[]
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
hideEntities = false
|
||||
) => {
|
||||
// Event Trigger
|
||||
if (trigger.trigger === "event" && trigger.event_type) {
|
||||
@@ -262,7 +267,12 @@ const describeLegacyTrigger = (
|
||||
}
|
||||
|
||||
// Numeric State Trigger
|
||||
if (trigger.trigger === "numeric_state") {
|
||||
if (
|
||||
trigger.trigger === "numeric_state" &&
|
||||
(trigger.entity_id || hideEntities)
|
||||
) {
|
||||
const states = hass.states;
|
||||
|
||||
const stateObj = Array.isArray(trigger.entity_id)
|
||||
? hass.states[trigger.entity_id[0]]
|
||||
: (hass.states[trigger.entity_id] as HassEntity | undefined);
|
||||
@@ -282,23 +292,82 @@ const describeLegacyTrigger = (
|
||||
? describeDuration(hass.locale, trigger.for)
|
||||
: undefined;
|
||||
|
||||
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,
|
||||
if (hideEntities) {
|
||||
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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -394,12 +463,39 @@ 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.changed`,
|
||||
`${triggerTranslationBaseKey}.state.description.full`,
|
||||
{
|
||||
hasAttribute: attribute !== "" ? "true" : "false",
|
||||
attribute: attribute,
|
||||
anyChange: toChoice === "special" ? "true" : "false",
|
||||
hasEntity: entities.length !== 0 ? "true" : "false",
|
||||
entity: formatListWithOrs(hass.locale, entities),
|
||||
fromChoice: fromChoice,
|
||||
fromString: fromString,
|
||||
toChoice: toChoice,
|
||||
@@ -941,7 +1037,8 @@ const tryDescribeCondition = (
|
||||
const description = describeLegacyCondition(
|
||||
condition as LegacyCondition,
|
||||
hass,
|
||||
entityRegistry
|
||||
entityRegistry,
|
||||
options?.hideEntities
|
||||
);
|
||||
|
||||
if (description) {
|
||||
@@ -967,7 +1064,8 @@ const tryDescribeCondition = (
|
||||
const describeLegacyCondition = (
|
||||
condition: LegacyCondition,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[]
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
hideEntities = false
|
||||
) => {
|
||||
if (condition.condition === "or") {
|
||||
const conditions = ensureArray(condition.conditions);
|
||||
@@ -1024,6 +1122,12 @@ 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]
|
||||
@@ -1080,14 +1184,51 @@ const describeLegacyCondition = (
|
||||
duration = describeDuration(hass.locale, condition.for) || "";
|
||||
}
|
||||
|
||||
if (states.length === 0) {
|
||||
return hass.localize(`${conditionsTranslationBaseKey}.state.label`);
|
||||
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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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.is`,
|
||||
`${conditionsTranslationBaseKey}.state.description.full`,
|
||||
{
|
||||
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,
|
||||
@@ -1096,7 +1237,10 @@ const describeLegacyCondition = (
|
||||
}
|
||||
|
||||
// Numeric State Condition
|
||||
if (condition.condition === "numeric_state") {
|
||||
if (
|
||||
condition.condition === "numeric_state" &&
|
||||
(condition.entity_id || hideEntities)
|
||||
) {
|
||||
const entity_ids = condition.entity_id
|
||||
? ensureArray(condition.entity_id)
|
||||
: [];
|
||||
@@ -1113,20 +1257,64 @@ const describeLegacyCondition = (
|
||||
: condition.attribute
|
||||
: undefined;
|
||||
|
||||
const suffix = numericThresholdSuffix(condition);
|
||||
if (!suffix) {
|
||||
if (hideEntities) {
|
||||
const suffix = numericThresholdSuffix(condition);
|
||||
if (!suffix) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.label`
|
||||
);
|
||||
}
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.label`
|
||||
`${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
|
||||
|
||||
+5
-38
@@ -3,16 +3,10 @@ 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 {
|
||||
@@ -108,15 +102,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 }>("POST", "cloud/login", rest);
|
||||
hass.callApi<{ success: boolean; cloud_pipeline?: string }>(
|
||||
"POST",
|
||||
"cloud/login",
|
||||
rest
|
||||
);
|
||||
|
||||
export const cloudLogout = (hass: HomeAssistant) =>
|
||||
hass.callApi("POST", "cloud/logout");
|
||||
@@ -136,33 +130,6 @@ 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,7 +25,6 @@ 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,69 +182,26 @@ export const deviceAutomationEditorMode = (
|
||||
: "unknown-device";
|
||||
};
|
||||
|
||||
const deviceAutomationsSameType = (a: DeviceAutomation, b: DeviceAutomation) =>
|
||||
deviceAutomationIdentifiers
|
||||
.filter((property) => property !== "device_id" && property !== "entity_id")
|
||||
.every((property) => Object.is(a[property], b[property]));
|
||||
|
||||
// An entity can be referenced by its registry id or by its entity id, and the
|
||||
// two sides do not have to agree.
|
||||
const deviceAutomationsSameEntity = (
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
// 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 = (
|
||||
a: DeviceAutomation,
|
||||
b: DeviceAutomation
|
||||
) => {
|
||||
if (!a.entity_id && !b.entity_id) {
|
||||
return true;
|
||||
}
|
||||
if (!a.entity_id || !b.entity_id) {
|
||||
if (typeof a !== typeof b) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
a.entity_id === b.entity_id ||
|
||||
compareEntityIdWithEntityRegId(entityRegistry, a.entity_id, b.entity_id)
|
||||
);
|
||||
};
|
||||
|
||||
// A device exposes the same automation type once per entity, so the same type
|
||||
// on another entity is a different automation, not an equivalent one.
|
||||
export const findEquivalentDeviceAutomation = <T extends DeviceAutomation>(
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
automations: T[],
|
||||
automation: DeviceAutomation
|
||||
): T | undefined =>
|
||||
automations.find(
|
||||
(candidate) =>
|
||||
deviceAutomationsSameType(candidate, automation) &&
|
||||
deviceAutomationsSameEntity(entityRegistry, candidate, automation)
|
||||
);
|
||||
|
||||
// Among the split devices that replaced a removed device, the ones that offer
|
||||
// the given automation. Nothing in the registry says which of them took it over,
|
||||
// so each candidate has to be asked.
|
||||
export const fetchReplacementDevices = async <T extends DeviceAutomation>(
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
automation: DeviceAutomation,
|
||||
compositeSplits: DeviceCompositeSplits,
|
||||
fetchDeviceAutomations: (callWS: CallWS, deviceId: string) => Promise<T[]>
|
||||
): Promise<string[]> => {
|
||||
const candidates =
|
||||
compositeSplits[automation.device_id]?.split_ids.filter(
|
||||
(id) => id in hass.devices
|
||||
) ?? [];
|
||||
const automationsPerCandidate = await Promise.all(
|
||||
candidates.map((id) =>
|
||||
fetchDeviceAutomations(hass.callWS, id).catch(() => [] as T[])
|
||||
)
|
||||
);
|
||||
return candidates.filter((_id, index) =>
|
||||
findEquivalentDeviceAutomation(
|
||||
entityRegistry,
|
||||
automationsPerCandidate[index],
|
||||
automation
|
||||
)
|
||||
);
|
||||
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]);
|
||||
});
|
||||
};
|
||||
|
||||
const compareEntityIdWithEntityRegId = (
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
// 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"],
|
||||
};
|
||||
+33
-50
@@ -1553,8 +1553,7 @@ export const formatConsumptionShort = (
|
||||
hass: HomeAssistant,
|
||||
consumption: number | null,
|
||||
unit: string,
|
||||
targetUnit?: string,
|
||||
displayPrecision?: number
|
||||
targetUnit?: string
|
||||
): string => {
|
||||
const units = ["Wh", "kWh", "MWh", "GWh", "TWh"];
|
||||
let pickedUnit = unit;
|
||||
@@ -1584,19 +1583,10 @@ export const formatConsumptionShort = (
|
||||
pickedUnit = units[unitIndex];
|
||||
}
|
||||
return (
|
||||
formatNumber(
|
||||
val,
|
||||
hass.locale,
|
||||
displayPrecision !== undefined && pickedUnit === unit
|
||||
? {
|
||||
minimumFractionDigits: displayPrecision,
|
||||
maximumFractionDigits: displayPrecision,
|
||||
}
|
||||
: {
|
||||
maximumFractionDigits:
|
||||
Math.abs(val) < 10 ? 2 : Math.abs(val) < 100 ? 1 : 0,
|
||||
}
|
||||
) +
|
||||
formatNumber(val, hass.locale, {
|
||||
maximumFractionDigits:
|
||||
Math.abs(val) < 10 ? 2 : Math.abs(val) < 100 ? 1 : 0,
|
||||
}) +
|
||||
" " +
|
||||
pickedUnit
|
||||
);
|
||||
@@ -1919,13 +1909,20 @@ export const downloadEnergyData = (
|
||||
const device_consumption_water = energyData.prefs.device_consumption_water;
|
||||
const stats = energyData.state.stats;
|
||||
|
||||
interface CsvRow {
|
||||
id: string;
|
||||
type: string;
|
||||
unit: string;
|
||||
data: StatisticValue[];
|
||||
}
|
||||
const rows: CsvRow[] = [];
|
||||
const timeSet = new Set<number>();
|
||||
Object.values(stats).forEach((stat) => {
|
||||
stat.forEach((datapoint) => {
|
||||
timeSet.add(datapoint.start);
|
||||
});
|
||||
});
|
||||
const times = Array.from(timeSet).sort();
|
||||
|
||||
const headers =
|
||||
"entity_id,type,unit," +
|
||||
times.map((t) => new Date(t).toISOString()).join(",") +
|
||||
"\n";
|
||||
const csv: string[] = [];
|
||||
csv[0] = headers;
|
||||
|
||||
const processCsvRow = function (
|
||||
id: string,
|
||||
@@ -1933,7 +1930,20 @@ export const downloadEnergyData = (
|
||||
unit: string,
|
||||
data: StatisticValue[]
|
||||
) {
|
||||
rows.push({ id, type, unit, data });
|
||||
let n = 0;
|
||||
const row: string[] = [];
|
||||
row.push(id);
|
||||
row.push(type);
|
||||
row.push(unit.normalize("NFKD"));
|
||||
times.forEach((t) => {
|
||||
if (n < data.length && data[n].start === t) {
|
||||
row.push((data[n].change ?? "").toString());
|
||||
n++;
|
||||
} else {
|
||||
row.push("");
|
||||
}
|
||||
});
|
||||
csv.push(row.join(",") + "\n");
|
||||
};
|
||||
|
||||
const processStat = function (stat: string, type: string, unit: string) {
|
||||
@@ -2180,33 +2190,6 @@ export const downloadEnergyData = (
|
||||
);
|
||||
}
|
||||
|
||||
const timeSet = new Set<number>();
|
||||
rows.forEach((row) => {
|
||||
row.data.forEach((datapoint) => {
|
||||
timeSet.add(datapoint.start);
|
||||
});
|
||||
});
|
||||
const times = Array.from(timeSet).sort();
|
||||
|
||||
const csv: string[] = [
|
||||
"entity_id,type,unit," +
|
||||
times.map((t) => new Date(t).toISOString()).join(",") +
|
||||
"\n",
|
||||
];
|
||||
rows.forEach(({ id, type, unit, data }) => {
|
||||
let n = 0;
|
||||
const row: string[] = [id, type, unit.normalize("NFKD")];
|
||||
times.forEach((t) => {
|
||||
if (n < data.length && data[n].start === t) {
|
||||
row.push((data[n].change ?? "").toString());
|
||||
n++;
|
||||
} else {
|
||||
row.push("");
|
||||
}
|
||||
});
|
||||
csv.push(row.join(",") + "\n");
|
||||
});
|
||||
|
||||
const blob = new Blob(csv, {
|
||||
type: "text/csv",
|
||||
});
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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;
|
||||
@@ -50,6 +50,9 @@ export const DOMAIN_ATTRIBUTES_UNITS = {
|
||||
azimuth: "°",
|
||||
elevation: "°",
|
||||
},
|
||||
vacuum: {
|
||||
battery_level: "%",
|
||||
},
|
||||
valve: {
|
||||
current_position: "%",
|
||||
},
|
||||
|
||||
@@ -31,6 +31,10 @@ export const entityComboBoxKeys: FuseWeightedKey[] = [
|
||||
name: "search_labels.deviceName",
|
||||
weight: 7,
|
||||
},
|
||||
{
|
||||
name: "search_labels.parentDeviceName",
|
||||
weight: 6,
|
||||
},
|
||||
{
|
||||
name: "search_labels.areaName",
|
||||
weight: 6,
|
||||
@@ -129,14 +133,20 @@ export const getEntities = (
|
||||
const stateObj = hass.states[entityId];
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const domain = computeDomain(entityId);
|
||||
let domainName = domainNames.get(domain);
|
||||
@@ -146,7 +156,11 @@ export const getEntities = (
|
||||
}
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
@@ -159,6 +173,7 @@ export const getEntities = (
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
domainName: domainName || null,
|
||||
friendlyName: friendlyName || null,
|
||||
|
||||
@@ -96,17 +96,10 @@ export interface ValveEntityOptions {
|
||||
favorite_positions?: number[];
|
||||
}
|
||||
|
||||
export interface TimerEntityOptions {
|
||||
presets?: number[];
|
||||
}
|
||||
|
||||
export type FavoriteOption =
|
||||
| "favorite_colors"
|
||||
| "favorite_positions"
|
||||
| "favorite_tilt_positions"
|
||||
| "presets";
|
||||
"favorite_colors" | "favorite_positions" | "favorite_tilt_positions";
|
||||
|
||||
export type FavoritesDomain = "light" | "cover" | "valve" | "timer";
|
||||
export type FavoritesDomain = "light" | "cover" | "valve";
|
||||
|
||||
export type FavoriteOptionValue = LightColor[] | number[];
|
||||
|
||||
@@ -114,7 +107,6 @@ export const DOMAINS_WITH_FAVORITES: FavoritesDomain[] = [
|
||||
"light",
|
||||
"cover",
|
||||
"valve",
|
||||
"timer",
|
||||
];
|
||||
|
||||
export const isFavoritesDomain = (domain: string): domain is FavoritesDomain =>
|
||||
@@ -181,7 +173,6 @@ export interface EntityRegistryOptions {
|
||||
light?: LightEntityOptions;
|
||||
cover?: CoverEntityOptions;
|
||||
valve?: ValveEntityOptions;
|
||||
timer?: TimerEntityOptions;
|
||||
vacuum?: VacuumEntityOptions;
|
||||
device_tracker?: DeviceTrackerEntityOptions;
|
||||
switch_as_x?: SwitchAsXEntityOptions;
|
||||
@@ -209,7 +200,6 @@ export interface EntityRegistryEntryUpdateParams {
|
||||
| LightEntityOptions
|
||||
| CoverEntityOptions
|
||||
| ValveEntityOptions
|
||||
| TimerEntityOptions
|
||||
| VacuumEntityOptions
|
||||
| DeviceTrackerEntityOptions;
|
||||
aliases?: (string | null)[];
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
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,18 +35,6 @@ 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),
|
||||
@@ -63,7 +51,6 @@ declare global {
|
||||
core: CoreFrontendSystemData;
|
||||
home: HomeFrontendSystemData;
|
||||
energy: EnergyFrontendSystemData;
|
||||
security: SecurityFrontendSystemData;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { timeCacheEntityPromiseFunc } from "../common/util/time-cache-entity-promise-func";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { MediaPlayerItem, SearchMediaResult } from "./media-player";
|
||||
|
||||
@@ -8,7 +7,7 @@ export interface ResolvedMediaSource {
|
||||
}
|
||||
|
||||
export const resolveMediaSource = (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
hass: HomeAssistant,
|
||||
media_content_id: string
|
||||
) =>
|
||||
hass.callWS<ResolvedMediaSource>({
|
||||
@@ -16,23 +15,6 @@ 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
|
||||
|
||||
@@ -40,7 +40,8 @@ export const searchPlaces = (
|
||||
limit ? `&limit=${limit}` : ""
|
||||
}${addressdetails ? "&addressdetails=1" : ""}&accept-language=${
|
||||
hass.locale.language
|
||||
}&email=abuse@home-assistant.io`
|
||||
}&email=abuse@home-assistant.io`,
|
||||
{ headers: { "User-Agent": `HomeAssistant/${hass.config.version}` } }
|
||||
).then((res) => {
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
@@ -58,7 +59,8 @@ export const reverseGeocode = (
|
||||
location[1]
|
||||
}&accept-language=${hass.locale.language}&zoom=${
|
||||
zoom ?? 18
|
||||
}&format=jsonv2&email=abuse@home-assistant.io`
|
||||
}&format=jsonv2&email=abuse@home-assistant.io`,
|
||||
{ headers: { "User-Agent": `HomeAssistant/${hass.config.version}` } }
|
||||
).then((res) => {
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
|
||||
@@ -160,6 +160,15 @@ export const getRecorderEntityOptions = (
|
||||
entity_id,
|
||||
});
|
||||
|
||||
export const getStatisticIds = (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
statistic_type?: "mean" | "sum"
|
||||
) =>
|
||||
hass.callWS<StatisticsMetaData[]>({
|
||||
type: "recorder/list_statistic_ids",
|
||||
statistic_type,
|
||||
});
|
||||
|
||||
export const getStatisticMetadata = (
|
||||
hass: HomeAssistant,
|
||||
statistic_ids?: string[]
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { shareInFlightRequest } from "../common/util/share-in-flight-request";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { StatisticsMetaData } from "./recorder";
|
||||
|
||||
type StatisticIdsType = "mean" | "sum";
|
||||
|
||||
type StatisticIdsApi = Pick<HomeAssistant, "callWS">;
|
||||
|
||||
export const getStatisticIds = (
|
||||
hass: StatisticIdsApi,
|
||||
statistic_type?: StatisticIdsType
|
||||
) =>
|
||||
shareInFlightRequest(
|
||||
hass.callWS,
|
||||
`recorder/list_statistic_ids:${statistic_type ?? "all"}`,
|
||||
() =>
|
||||
hass.callWS<StatisticsMetaData[]>({
|
||||
type: "recorder/list_statistic_ids",
|
||||
statistic_type,
|
||||
})
|
||||
);
|
||||
+1
-23
@@ -48,7 +48,6 @@ export type Selector =
|
||||
| DeviceSelector
|
||||
| FloorSelector
|
||||
| LegacyDeviceSelector
|
||||
| DeviceClassSelector
|
||||
| DurationSelector
|
||||
| EntitySelector
|
||||
| EntityNameSelector
|
||||
@@ -88,20 +87,6 @@ export type Selector =
|
||||
| UiTimeFormatSelector
|
||||
| BackupLocationSelector;
|
||||
|
||||
type KeysOfUnion<T> = T extends T ? keyof T : never;
|
||||
export type SelectorType = KeysOfUnion<Selector>;
|
||||
|
||||
type UnionMemberWithKey<U, K extends PropertyKey> = U extends unknown
|
||||
? K extends keyof U
|
||||
? U
|
||||
: never
|
||||
: never;
|
||||
|
||||
export type SelectorForType<T extends SelectorType> = UnionMemberWithKey<
|
||||
Selector,
|
||||
T
|
||||
>;
|
||||
|
||||
export interface ActionSelector {
|
||||
action: {
|
||||
optionsInSidebar?: boolean;
|
||||
@@ -380,9 +365,9 @@ export interface MediaSelector {
|
||||
media: {
|
||||
accept?: string[];
|
||||
image_upload?: boolean;
|
||||
clearable?: boolean;
|
||||
hide_content_type?: boolean;
|
||||
content_id_helper?: string;
|
||||
multiple?: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -499,13 +484,6 @@ export interface SelectSelector {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface DeviceClassSelector {
|
||||
device_class: {
|
||||
domain: string;
|
||||
multiple?: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface SelectorSelector {
|
||||
selector: {} | null;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user