mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-16 21:32:15 +00:00
Compare commits
123 Commits
dirty-state-5
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 9414bbc6ab | |||
| 287aabc9a3 | |||
| 2d505048c5 | |||
| e07cbb9164 | |||
| 9c56ce6386 | |||
| 30fd803506 | |||
| 3ed9b7df8d | |||
| 1c38d80ab2 | |||
| 1c579e207f | |||
| 1b27445485 | |||
| e977d4a9ec | |||
| 001a842d2f | |||
| 473be7f8c8 | |||
| 0d9b257d4e | |||
| 22786df070 | |||
| e5c849359b | |||
| 4bfa4f2816 | |||
| afd86975d6 | |||
| 7b1eff9eef | |||
| 4f0c228756 | |||
| c86101ac6e | |||
| 29fa351b16 | |||
| 7c67633146 | |||
| 180e23ad9b | |||
| 9e7ddb3e5e | |||
| 4a0e46dc2c | |||
| 6af0040e73 | |||
| ba58ef6dc2 | |||
| fafbd7a674 | |||
| 07290a5d7e | |||
| 06141043a7 | |||
| 03e4f968b4 | |||
| 17d4f67f69 | |||
| 133a9171bc | |||
| c2adc2b84a | |||
| 2e3cbf6aab | |||
| 82b2a60f32 | |||
| 2eb1811524 | |||
| 04b284159a | |||
| ddce581fdb | |||
| 668a7df5cd | |||
| d7cad1becd | |||
| 1e412ad035 | |||
| 11611cd597 | |||
| 0d545d744b | |||
| f39dab2de5 | |||
| 1527117015 | |||
| 26794560ac | |||
| 976f9de8ad | |||
| 6810bc5412 | |||
| a4ca54b80b | |||
| 07f0ef0ded | |||
| cf89bb32ab | |||
| ec5cbd16d8 | |||
| 926abd7fc5 | |||
| e227bbe9a2 | |||
| f82b0b61e5 | |||
| a62c89ee00 | |||
| 4b6d07134c | |||
| 35829c301e | |||
| a73f587591 | |||
| 2e5f776af7 | |||
| e91cffe27c | |||
| adbca5145c | |||
| 59d5ded6a5 | |||
| daba5dd8be | |||
| 1e3e43ba46 | |||
| e4cc1eaad2 | |||
| 9aa687577f | |||
| 2556707370 | |||
| 854c57c0e0 | |||
| 055076c45e | |||
| d4ec72006d | |||
| 393d6a8a0a | |||
| 4a030884f5 | |||
| f65596cad8 | |||
| 1449c17fd1 | |||
| ce0e6a7665 | |||
| 460dace974 | |||
| 7111d8a8a8 | |||
| b96d1f2809 | |||
| 26bdff9a16 | |||
| 16ac66c1f8 | |||
| 8533dd586b | |||
| 2cfb947c9b | |||
| 466cf2dfb2 | |||
| 193bcad917 | |||
| 52d32aec42 | |||
| 9adb7215ce | |||
| 273967fe70 | |||
| 382e07379b | |||
| 01a8b8d3ef | |||
| 3bbce5607e | |||
| 7ce052e2a8 | |||
| e929558a9a | |||
| 9cd4a6937f | |||
| af617695b8 | |||
| 740ad9eb6b | |||
| caeedc41e3 | |||
| fbb76a8ba0 | |||
| 3340637ff3 | |||
| 534bea231c | |||
| 8635951394 | |||
| c46f286cb8 | |||
| cc6b51d53f | |||
| 6915ca8fdd | |||
| 677e53f685 | |||
| 46b6ae8d7b | |||
| 09fda1ca1e | |||
| 7c1522b975 | |||
| d26ad7b354 | |||
| 66235a4c99 | |||
| 6c02864334 | |||
| 3471cd103a | |||
| 9ae25d96f2 | |||
| 02361f2517 | |||
| 38055b9244 | |||
| d064127f18 | |||
| cb2d8db91b | |||
| 861d7757cc | |||
| 1331ec9e2d | |||
| 0f81311c76 | |||
| 8a85d1cf31 |
@@ -289,6 +289,7 @@ For browser support, API details, and current specifications, refer to these aut
|
||||
- **Test with Vitest**: Use the established test framework
|
||||
- **Mock appropriately**: Mock WebSocket connections and API calls
|
||||
- **Test accessibility**: Ensure components are accessible
|
||||
- **Optimizing chart data processing**: When optimizing chart data transforms (history, statistics, energy, downsampling), follow the playbook in [`test/benchmarks/README.md`](test/benchmarks/README.md) — it has seeded fixtures, characterization (snapshot) tests that pin current output, and `vitest bench` benchmarks (`yarn test:bench`) for before/after comparison. Optimizations must keep output bit-identical.
|
||||
|
||||
## Component Library
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Blocking labels
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- labeled
|
||||
- unlabeled
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check for labels which block the Pull Request from being merged
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check for blocking labels
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const blockingLabels = [
|
||||
"wait for backend",
|
||||
"Needs UX",
|
||||
"Do Not Review",
|
||||
"Blocked",
|
||||
"has-parent",
|
||||
];
|
||||
const prLabels = context.payload.pull_request.labels.map(
|
||||
(l) => l.name
|
||||
);
|
||||
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
|
||||
if (found.length > 0) {
|
||||
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
|
||||
await core.summary
|
||||
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
|
||||
.addRaw(message)
|
||||
.write();
|
||||
core.setFailed(message);
|
||||
} else {
|
||||
await core.summary
|
||||
.addHeading(":white_check_mark: Pull Request is clear to merge after review", 2)
|
||||
.addRaw("This Pull Request is not blocked by any labels which prevent it from being merged.")
|
||||
.write();
|
||||
}
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
@@ -41,14 +41,14 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -62,4 +62,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@868e6cb4607727d764341a158d98872cd63fa658 # master
|
||||
uses: home-assistant/actions/helpers/verify-version@e91ad1948e57189485b9c1ad608af0c303946f89 # master
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
|
||||
# home-assistant/wheels doesn't support SHA pinning
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
|
||||
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
|
||||
with:
|
||||
abi: cp314
|
||||
tag: musllinux_1_2
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
contents: write # Required to upload release assets
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Sync numeric device classes
|
||||
|
||||
# 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:
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Regenerate numeric device classes
|
||||
run: ./script/gen_numeric_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`.
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -58,3 +58,4 @@ test/coverage/
|
||||
.claude
|
||||
.cursor
|
||||
.opencode
|
||||
test/benchmarks/results/
|
||||
|
||||
@@ -103,12 +103,29 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
|
||||
|
||||
if (!toProcess) {
|
||||
console.error("Unknown category", group.category);
|
||||
if (!group.pages) {
|
||||
if (!group.subsections && !group.pages) {
|
||||
group.pages = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (group.subsections) {
|
||||
// Listed pages keep their per-subsection order.
|
||||
for (const subsection of group.subsections) {
|
||||
for (const page of subsection.pages) {
|
||||
if (!toProcess.delete(page)) {
|
||||
console.error("Found unreferenced demo", page);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Any remaining pages land in a trailing "Other" subsection.
|
||||
const leftover = Array.from(toProcess).sort();
|
||||
if (leftover.length) {
|
||||
group.subsections.push({ header: "Other", pages: leftover });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Any pre-defined groups will not be sorted.
|
||||
if (group.pages) {
|
||||
for (const page of group.pages) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import "./fetch-nightly-translations.js";
|
||||
import "./gallery.js";
|
||||
import "./gather-static.js";
|
||||
import "./gen-icons-json.js";
|
||||
import "./gen-numeric-device-classes.js";
|
||||
import "./landing-page.js";
|
||||
import "./locale-data.js";
|
||||
import "./rspack.js";
|
||||
|
||||
+38
-3
@@ -8,7 +8,7 @@ import type { HomeAssistant } from "../../src/types";
|
||||
import { selectedDemoConfig } from "./configs/demo-configs";
|
||||
import { mockAreaRegistry } from "./stubs/area_registry";
|
||||
import { mockAuth } from "./stubs/auth";
|
||||
import { mockConfigEntries } from "./stubs/config_entries";
|
||||
import { demoDevices } from "./stubs/devices";
|
||||
import { mockDeviceRegistry } from "./stubs/device_registry";
|
||||
import { mockEnergy } from "./stubs/energy";
|
||||
import { energyEntities } from "./stubs/entities";
|
||||
@@ -16,6 +16,7 @@ import { mockEntityRegistry } from "./stubs/entity_registry";
|
||||
import { mockEvents } from "./stubs/events";
|
||||
import { mockFloorRegistry } from "./stubs/floor_registry";
|
||||
import { mockFrontend } from "./stubs/frontend";
|
||||
import { mockIntegration } from "./stubs/integration";
|
||||
import { mockLabelRegistry } from "./stubs/label_registry";
|
||||
import { mockIcons } from "./stubs/icons";
|
||||
import { mockHistory } from "./stubs/history";
|
||||
@@ -29,6 +30,31 @@ import { mockTemplate } from "./stubs/template";
|
||||
import { mockTodo } from "./stubs/todo";
|
||||
import { mockTranslations } from "./stubs/translations";
|
||||
|
||||
// WS command / REST path prefixes whose mocks live in the lazily imported
|
||||
// config-panel chunk (see ./stubs/config-panel). Must stay in sync with it.
|
||||
const CONFIG_PANEL_COMMANDS = [
|
||||
"cloud/",
|
||||
"validate_config",
|
||||
"config_entries/",
|
||||
"device_automation/",
|
||||
"entity/source",
|
||||
"blueprint/",
|
||||
"homeassistant/expose",
|
||||
"zone/list",
|
||||
"person/list",
|
||||
"network/url",
|
||||
"application_credentials/",
|
||||
"system_health/",
|
||||
"backup/",
|
||||
"automation/config",
|
||||
"script/config",
|
||||
"config/automation/config",
|
||||
"config/script/config",
|
||||
"config/scene/config",
|
||||
"search/related",
|
||||
"tag/list",
|
||||
];
|
||||
|
||||
@customElement("ha-demo")
|
||||
export class HaDemo extends HomeAssistantAppEl {
|
||||
protected async _initializeHass() {
|
||||
@@ -61,9 +87,18 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
mockIcons(hass);
|
||||
mockEnergy(hass);
|
||||
mockPersistentNotification(hass);
|
||||
mockConfigEntries(hass);
|
||||
// Consumed app-wide via the lazy manifests context, so register eagerly.
|
||||
mockIntegration(hass);
|
||||
// Config panel mocks are code-split: the loader runs (and the chunk is
|
||||
// dynamically imported) the first time one of these config-only WS/REST
|
||||
// commands is requested, i.e. when the config panel is opened.
|
||||
hass.mockLazyLoad(
|
||||
(command) => CONFIG_PANEL_COMMANDS.some((p) => command.startsWith(p)),
|
||||
() =>
|
||||
import("./stubs/config-panel").then((mod) => mod.mockConfigPanel(hass))
|
||||
);
|
||||
mockAreaRegistry(hass);
|
||||
mockDeviceRegistry(hass);
|
||||
mockDeviceRegistry(hass, demoDevices);
|
||||
mockFloorRegistry(hass);
|
||||
mockLabelRegistry(hass);
|
||||
mockEntityRegistry(hass, [
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ApplicationCredential } from "../../../src/data/application_credential";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const credentials: ApplicationCredential[] = [
|
||||
{
|
||||
id: "mock-credential",
|
||||
domain: "spotify",
|
||||
client_id: "demo-client-id",
|
||||
client_secret: "demo-client-secret",
|
||||
name: "Spotify",
|
||||
},
|
||||
];
|
||||
|
||||
export const mockApplicationCredentials = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("application_credentials/list", () => credentials);
|
||||
hass.mockWS("application_credentials/config", () => ({
|
||||
integrations: { spotify: { description_placeholders: {} } },
|
||||
}));
|
||||
};
|
||||
@@ -3,4 +3,7 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
export const mockAuth = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("config/auth/list", () => []);
|
||||
hass.mockWS("auth/refresh_tokens", () => []);
|
||||
hass.mockWS("auth/sign_path", (msg: { path: string }) => ({
|
||||
path: msg.path,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { AutomationConfig } from "../../../src/data/automation";
|
||||
import type { ScriptConfig } from "../../../src/data/script";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const demoAutomationConfig = (entityId: string): AutomationConfig => ({
|
||||
id: entityId.split(".")[1],
|
||||
alias: "Demo automation",
|
||||
description: "An example automation shown in the demo.",
|
||||
triggers: [
|
||||
{ trigger: "state", entity_id: "binary_sensor.basement_floor_wet" },
|
||||
],
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.bed_light" },
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
});
|
||||
|
||||
const demoScriptConfig = (): ScriptConfig => ({
|
||||
alias: "Demo script",
|
||||
description: "An example script shown in the demo.",
|
||||
sequence: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.bed_light" },
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
});
|
||||
|
||||
export const mockAutomation = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("automation/config", (msg: { entity_id: string }) => ({
|
||||
config: demoAutomationConfig(msg.entity_id),
|
||||
}));
|
||||
hass.mockWS("script/config", () => ({ config: demoScriptConfig() }));
|
||||
|
||||
hass.mockAPI(/config\/automation\/config\/.+/, () =>
|
||||
demoAutomationConfig("automation.demo")
|
||||
);
|
||||
hass.mockAPI(/config\/script\/config\/.+/, () => demoScriptConfig());
|
||||
|
||||
// Trigger/condition type pickers subscribe for integration-provided
|
||||
// platforms. The demo only uses the built-in ones, so emit empty records.
|
||||
hass.mockWS(
|
||||
"trigger_platforms/subscribe",
|
||||
(
|
||||
_msg,
|
||||
_hass,
|
||||
onChange?: (descriptions: Record<string, unknown>) => void
|
||||
) => {
|
||||
onChange?.({});
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
hass.mockWS(
|
||||
"condition_platforms/subscribe",
|
||||
(
|
||||
_msg,
|
||||
_hass,
|
||||
onChange?: (descriptions: Record<string, unknown>) => void
|
||||
) => {
|
||||
onChange?.({});
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import type {
|
||||
BackupAgentsInfo,
|
||||
BackupConfig,
|
||||
BackupContent,
|
||||
BackupInfo,
|
||||
} from "../../../src/data/backup";
|
||||
import { BackupScheduleRecurrence } from "../../../src/data/backup";
|
||||
import type { ManagerStateEvent } from "../../../src/data/backup_manager";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const lastBackupDate = new Date(Date.now() - 86400000).toISOString();
|
||||
const nextBackupDate = new Date(Date.now() + 86400000).toISOString();
|
||||
|
||||
const backups: BackupContent[] = [
|
||||
{
|
||||
backup_id: "demo-backup-1",
|
||||
name: "Automatic backup DEMO",
|
||||
date: lastBackupDate,
|
||||
with_automatic_settings: true,
|
||||
agents: {
|
||||
"backup.local": { size: 1024 * 1024 * 512, protected: true },
|
||||
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const backupInfo: BackupInfo = {
|
||||
backups,
|
||||
agent_errors: {},
|
||||
last_attempted_automatic_backup: lastBackupDate,
|
||||
last_completed_automatic_backup: lastBackupDate,
|
||||
last_action_event: { manager_state: "idle" },
|
||||
next_automatic_backup: nextBackupDate,
|
||||
next_automatic_backup_additional: false,
|
||||
state: "idle",
|
||||
};
|
||||
|
||||
const backupConfig: BackupConfig = {
|
||||
automatic_backups_configured: true,
|
||||
last_attempted_automatic_backup: lastBackupDate,
|
||||
last_completed_automatic_backup: lastBackupDate,
|
||||
next_automatic_backup: nextBackupDate,
|
||||
next_automatic_backup_additional: false,
|
||||
create_backup: {
|
||||
agent_ids: ["backup.local", "cloud.cloud"],
|
||||
include_addons: [],
|
||||
include_all_addons: true,
|
||||
include_database: true,
|
||||
include_folders: [],
|
||||
name: null,
|
||||
password: null,
|
||||
},
|
||||
retention: { copies: 3, days: null },
|
||||
schedule: {
|
||||
recurrence: BackupScheduleRecurrence.DAILY,
|
||||
time: null,
|
||||
days: [],
|
||||
},
|
||||
agents: {
|
||||
"backup.local": { protected: true, retention: null },
|
||||
"cloud.cloud": { protected: true, retention: null },
|
||||
},
|
||||
};
|
||||
|
||||
const agentsInfo: BackupAgentsInfo = {
|
||||
agents: [
|
||||
{ agent_id: "backup.local", name: "This device" },
|
||||
{ agent_id: "cloud.cloud", name: "Home Assistant Cloud" },
|
||||
],
|
||||
};
|
||||
|
||||
export const mockBackup = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("backup/info", () => backupInfo);
|
||||
hass.mockWS("backup/config/info", () => ({ config: backupConfig }));
|
||||
hass.mockWS("backup/agents/info", () => agentsInfo);
|
||||
hass.mockWS(
|
||||
"backup/subscribe_events",
|
||||
(_msg, _hass, onChange?: (event: ManagerStateEvent) => void) => {
|
||||
onChange?.({ manager_state: "idle" });
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { BlueprintDomain, Blueprints } from "../../../src/data/blueprint";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const automationBlueprints: Blueprints = {
|
||||
"homeassistant/motion_light.yaml": {
|
||||
metadata: {
|
||||
domain: "automation",
|
||||
name: "Motion-activated Light",
|
||||
description: "Turn on a light when motion is detected.",
|
||||
author: "Home Assistant",
|
||||
source_url:
|
||||
"https://github.com/home-assistant/core/blob/dev/homeassistant/components/automation/blueprints/motion_light.yaml",
|
||||
input: {
|
||||
motion_entity: { name: "Motion Sensor" },
|
||||
light_target: { name: "Light" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"homeassistant/notify_leaving_zone.yaml": {
|
||||
metadata: {
|
||||
domain: "automation",
|
||||
name: "Send notification when leaving a zone",
|
||||
description: "Get a notification when a person leaves a zone.",
|
||||
author: "Home Assistant",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const scriptBlueprints: Blueprints = {
|
||||
"homeassistant/confirmable_notification.yaml": {
|
||||
metadata: {
|
||||
domain: "script",
|
||||
name: "Confirmable Notification",
|
||||
description:
|
||||
"A script that sends an actionable notification with a confirmation.",
|
||||
author: "Home Assistant",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockBlueprint = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("blueprint/list", (msg: { domain: BlueprintDomain }) =>
|
||||
msg.domain === "script" ? scriptBlueprints : automationBlueprints
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import type {
|
||||
CloudStatusLoggedIn,
|
||||
SubscriptionInfo,
|
||||
} from "../../../src/data/cloud";
|
||||
import type { CloudTTSInfo } from "../../../src/data/cloud/tts";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const emptyFilter = () => ({
|
||||
include_domains: [],
|
||||
include_entities: [],
|
||||
exclude_domains: [],
|
||||
exclude_entities: [],
|
||||
});
|
||||
|
||||
// A single mutable status object so that preference changes made in the demo
|
||||
// are reflected back in the UI.
|
||||
const cloudStatus: CloudStatusLoggedIn = {
|
||||
logged_in: true,
|
||||
cloud: "connected",
|
||||
cloud_last_disconnect_reason: null,
|
||||
email: "demo@home-assistant.io",
|
||||
google_registered: true,
|
||||
google_entities: emptyFilter(),
|
||||
google_domains: ["light", "switch", "climate", "cover"],
|
||||
alexa_registered: true,
|
||||
alexa_entities: emptyFilter(),
|
||||
remote_domain: "demo-instance.ui.nabu.casa",
|
||||
remote_connected: true,
|
||||
remote_certificate: {
|
||||
common_name: "demo-instance.ui.nabu.casa",
|
||||
expire_date: "2099-01-01T00:00:00+00:00",
|
||||
fingerprint: "demodemodemodemodemodemodemodemodemodemodemodemodemo",
|
||||
alternative_names: ["demo-instance.ui.nabu.casa"],
|
||||
},
|
||||
remote_certificate_status: "ready",
|
||||
http_use_ssl: false,
|
||||
active_subscription: true,
|
||||
prefs: {
|
||||
google_enabled: true,
|
||||
alexa_enabled: true,
|
||||
remote_enabled: true,
|
||||
remote_allow_remote_enable: true,
|
||||
strict_connection: "disabled",
|
||||
google_secure_devices_pin: undefined,
|
||||
cloudhooks: {},
|
||||
alexa_report_state: true,
|
||||
google_report_state: true,
|
||||
tts_default_voice: ["en-US", "JennyNeural"],
|
||||
cloud_ice_servers_enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
const subscription: SubscriptionInfo = {
|
||||
human_description: "Demo subscription, renews automatically",
|
||||
provider: "Nabu Casa, Inc.",
|
||||
plan_renewal_date: 4102444800,
|
||||
};
|
||||
|
||||
const ttsInfo: CloudTTSInfo = {
|
||||
languages: [
|
||||
["en-US", "JennyNeural", "Jenny"],
|
||||
["en-US", "GuyNeural", "Guy"],
|
||||
["en-GB", "LibbyNeural", "Libby"],
|
||||
["nl-NL", "ColetteNeural", "Colette"],
|
||||
["de-DE", "KatjaNeural", "Katja"],
|
||||
],
|
||||
};
|
||||
|
||||
export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("cloud/status", () => cloudStatus);
|
||||
hass.mockWS("cloud/subscription", () => subscription);
|
||||
hass.mockWS("cloud/tts/info", () => ttsInfo);
|
||||
|
||||
hass.mockWS("cloud/update_prefs", (msg) => {
|
||||
const { type, ...prefs } = msg;
|
||||
cloudStatus.prefs = { ...cloudStatus.prefs, ...prefs };
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/cloudhook/create", (msg) => {
|
||||
const webhook = {
|
||||
webhook_id: msg.webhook_id,
|
||||
cloudhook_id: "demo-cloudhook-id",
|
||||
cloudhook_url: `https://hooks.nabu.casa/demo-${msg.webhook_id}`,
|
||||
managed: false,
|
||||
};
|
||||
cloudStatus.prefs.cloudhooks = {
|
||||
...cloudStatus.prefs.cloudhooks,
|
||||
[msg.webhook_id]: webhook,
|
||||
};
|
||||
return webhook;
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/cloudhook/delete", (msg) => {
|
||||
const cloudhooks = { ...cloudStatus.prefs.cloudhooks };
|
||||
delete cloudhooks[msg.webhook_id];
|
||||
cloudStatus.prefs.cloudhooks = cloudhooks;
|
||||
return null;
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/remote/connect", () => {
|
||||
cloudStatus.remote_connected = true;
|
||||
return null;
|
||||
});
|
||||
hass.mockWS("cloud/remote/disconnect", () => {
|
||||
cloudStatus.remote_connected = false;
|
||||
return null;
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/remove_data", () => null);
|
||||
hass.mockWS("cloud/google_assistant/entities/update", () => null);
|
||||
hass.mockWS("cloud/alexa/entities", () => []);
|
||||
hass.mockWS("cloud/google_assistant/entities", () => []);
|
||||
|
||||
hass.mockAPI("cloud/logout", () => ({}));
|
||||
hass.mockAPI("cloud/google_actions/sync", () => ({}));
|
||||
hass.mockAPI("cloud/support_package", () => "Demo support package");
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { mockApplicationCredentials } from "./application_credentials";
|
||||
import { mockAutomation } from "./automation";
|
||||
import { mockBackup } from "./backup";
|
||||
import { mockBlueprint } from "./blueprint";
|
||||
import { mockCloud } from "./cloud";
|
||||
import { mockConfig } from "./config";
|
||||
import { mockConfigEntries } from "./config_entries";
|
||||
import { mockDeviceAutomation } from "./device_automation";
|
||||
import { mockEntitySources } from "./entity_sources";
|
||||
import { mockExpose } from "./expose";
|
||||
import { mockNetwork } from "./network";
|
||||
import { mockPerson } from "./person";
|
||||
import { mockScene } from "./scene";
|
||||
import { mockSearch } from "./search";
|
||||
import { mockSystemHealth } from "./system_health";
|
||||
import { mockTags } from "./tags";
|
||||
import { mockZone } from "./zone";
|
||||
|
||||
// Registers every mock that is only needed once the config panel is opened.
|
||||
// This module is dynamically imported so its data stays out of the main bundle.
|
||||
export const mockConfigPanel = (hass: MockHomeAssistant) => {
|
||||
mockCloud(hass);
|
||||
mockConfig(hass);
|
||||
mockConfigEntries(hass);
|
||||
mockDeviceAutomation(hass);
|
||||
mockEntitySources(hass);
|
||||
mockBlueprint(hass);
|
||||
mockExpose(hass);
|
||||
mockZone(hass);
|
||||
mockPerson(hass);
|
||||
mockNetwork(hass);
|
||||
mockApplicationCredentials(hass);
|
||||
mockSystemHealth(hass);
|
||||
mockBackup(hass);
|
||||
mockAutomation(hass);
|
||||
mockScene(hass);
|
||||
mockSearch(hass);
|
||||
mockTags(hass);
|
||||
};
|
||||
@@ -1,26 +1,126 @@
|
||||
import type { getConfigEntries } from "../../../src/data/config_entries";
|
||||
import type {
|
||||
ConfigEntry,
|
||||
ConfigEntryUpdate,
|
||||
} from "../../../src/data/config_entries";
|
||||
import type { ConfigFlowInProgressMessage } from "../../../src/data/config_flow";
|
||||
import type { IntegrationType } from "../../../src/data/integration";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockConfigEntries = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS<typeof getConfigEntries>("config_entries/get", () => [
|
||||
{
|
||||
entry_id: "mock-entry-co2signal",
|
||||
const baseEntry = {
|
||||
source: "user",
|
||||
state: "loaded" as const,
|
||||
supports_options: false,
|
||||
supports_remove_device: false,
|
||||
supports_unload: true,
|
||||
supports_reconfigure: true,
|
||||
supported_subentry_types: {},
|
||||
num_subentries: 0,
|
||||
pref_disable_new_entities: false,
|
||||
pref_disable_polling: false,
|
||||
disabled_by: null,
|
||||
reason: null,
|
||||
error_reason_translation_key: null,
|
||||
error_reason_translation_placeholders: null,
|
||||
};
|
||||
|
||||
// Each entry is tagged with its integration type so we can honor the
|
||||
// `type_filter` that the integrations and helpers panels subscribe with.
|
||||
export const demoConfigEntries: {
|
||||
entry: ConfigEntry;
|
||||
type: IntegrationType;
|
||||
}[] = [
|
||||
{
|
||||
type: "service",
|
||||
entry: {
|
||||
...baseEntry,
|
||||
entry_id: "co2signal",
|
||||
domain: "co2signal",
|
||||
title: "Electricity Maps",
|
||||
source: "user",
|
||||
state: "loaded",
|
||||
supports_options: false,
|
||||
supports_remove_device: false,
|
||||
supports_unload: true,
|
||||
supports_reconfigure: true,
|
||||
supported_subentry_types: {},
|
||||
pref_disable_new_entities: false,
|
||||
pref_disable_polling: false,
|
||||
disabled_by: null,
|
||||
reason: null,
|
||||
num_subentries: 0,
|
||||
error_reason_translation_key: null,
|
||||
error_reason_translation_placeholders: null,
|
||||
},
|
||||
]);
|
||||
},
|
||||
{
|
||||
type: "hub",
|
||||
entry: {
|
||||
...baseEntry,
|
||||
entry_id: "mock-hue",
|
||||
domain: "hue",
|
||||
title: "Philips Hue",
|
||||
source: "zeroconf",
|
||||
supports_options: true,
|
||||
supports_remove_device: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "hub",
|
||||
entry: {
|
||||
...baseEntry,
|
||||
entry_id: "mock-sonos",
|
||||
domain: "sonos",
|
||||
title: "Sonos",
|
||||
source: "zeroconf",
|
||||
supports_options: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "service",
|
||||
entry: {
|
||||
...baseEntry,
|
||||
entry_id: "mock-met",
|
||||
domain: "met",
|
||||
title: "Forecast.Home",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "helper",
|
||||
entry: {
|
||||
...baseEntry,
|
||||
entry_id: "mock-template-helper",
|
||||
domain: "template",
|
||||
title: "Comfort level",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const filterEntries = (filters?: {
|
||||
type_filter?: IntegrationType[];
|
||||
domain?: string;
|
||||
}): ConfigEntry[] =>
|
||||
demoConfigEntries
|
||||
.filter(
|
||||
(e) =>
|
||||
(!filters?.type_filter || filters.type_filter.includes(e.type)) &&
|
||||
(!filters?.domain || filters.domain === e.entry.domain)
|
||||
)
|
||||
.map((e) => e.entry);
|
||||
|
||||
export const mockConfigEntries = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"config_entries/get",
|
||||
(msg: { type_filter?: IntegrationType[]; domain?: string }) =>
|
||||
filterEntries(msg)
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"config_entries/subscribe",
|
||||
(
|
||||
msg: { type_filter?: IntegrationType[]; domain?: string },
|
||||
_hass,
|
||||
onChange?: (updates: ConfigEntryUpdate[]) => void
|
||||
) => {
|
||||
onChange?.(filterEntries(msg).map((entry) => ({ type: null, entry })));
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"config_entries/flow/subscribe",
|
||||
(
|
||||
_msg,
|
||||
_hass,
|
||||
onChange?: (updates: ConfigFlowInProgressMessage[]) => void
|
||||
) => {
|
||||
onChange?.([]);
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
// The demo's devices don't expose device-specific automations, so report empty
|
||||
// lists and no extra capability fields for the device automation pickers.
|
||||
export const mockDeviceAutomation = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("device_automation/trigger/list", () => []);
|
||||
hass.mockWS("device_automation/condition/list", () => []);
|
||||
hass.mockWS("device_automation/action/list", () => []);
|
||||
hass.mockWS("device_automation/trigger/capabilities", () => ({
|
||||
extra_fields: [],
|
||||
}));
|
||||
hass.mockWS("device_automation/condition/capabilities", () => ({
|
||||
extra_fields: [],
|
||||
}));
|
||||
hass.mockWS("device_automation/action/capabilities", () => ({
|
||||
extra_fields: [],
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
|
||||
const baseDevice = {
|
||||
config_entries_subentries: {},
|
||||
connections: [] as [string, string][],
|
||||
identifiers: [] as [string, string][],
|
||||
model_id: null,
|
||||
labels: [] as string[],
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
serial_number: null,
|
||||
via_device_id: null,
|
||||
area_id: null,
|
||||
name_by_user: null,
|
||||
disabled_by: null,
|
||||
configuration_url: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
|
||||
export const demoDevices: DeviceRegistryEntry[] = [
|
||||
{
|
||||
...baseDevice,
|
||||
id: "co2signal",
|
||||
name: "Electricity Maps",
|
||||
manufacturer: "Electricity Maps",
|
||||
model: "CO2 Signal",
|
||||
config_entries: ["co2signal"],
|
||||
primary_config_entry: "co2signal",
|
||||
entry_type: "service",
|
||||
},
|
||||
{
|
||||
...baseDevice,
|
||||
id: "hue-bridge",
|
||||
name: "Philips Hue Bridge",
|
||||
manufacturer: "Signify",
|
||||
model: "Hue Bridge (BSB002)",
|
||||
sw_version: "1.50.0",
|
||||
config_entries: ["mock-hue"],
|
||||
primary_config_entry: "mock-hue",
|
||||
entry_type: null,
|
||||
},
|
||||
{
|
||||
...baseDevice,
|
||||
id: "sonos-living",
|
||||
name: "Living Room",
|
||||
manufacturer: "Sonos",
|
||||
model: "One",
|
||||
config_entries: ["mock-sonos"],
|
||||
primary_config_entry: "mock-sonos",
|
||||
entry_type: null,
|
||||
},
|
||||
];
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
|
||||
import type {
|
||||
EntityRegistryEntry,
|
||||
ExtEntityRegistryEntry,
|
||||
} from "../../../src/data/entity/entity_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockEntityRegistry = (
|
||||
@@ -6,4 +9,17 @@ export const mockEntityRegistry = (
|
||||
data: EntityRegistryEntry[] = []
|
||||
) => {
|
||||
hass.mockWS("config/entity_registry/list", () => data);
|
||||
hass.mockWS(
|
||||
"config/entity_registry/get_entries",
|
||||
(msg: { entity_ids: string[] }) => {
|
||||
const result: Record<string, ExtEntityRegistryEntry> = {};
|
||||
for (const entityId of msg.entity_ids) {
|
||||
const entry = data.find((e) => e.entity_id === entityId);
|
||||
if (entry) {
|
||||
result[entityId] = { ...entry, capabilities: {}, aliases: [] };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { EntitySources } from "../../../src/data/entity/entity_sources";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockEntitySources = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"entity/source",
|
||||
(): EntitySources => ({
|
||||
"sensor.co2_intensity": { domain: "co2signal" },
|
||||
"sensor.grid_fossil_fuel_percentage": { domain: "co2signal" },
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ExposeEntitySettings } from "../../../src/data/expose";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const exposedEntities: Record<string, ExposeEntitySettings> = {
|
||||
"light.bed_light": {
|
||||
conversation: true,
|
||||
"cloud.alexa": true,
|
||||
"cloud.google_assistant": true,
|
||||
},
|
||||
"light.ceiling_lights": {
|
||||
conversation: true,
|
||||
"cloud.alexa": true,
|
||||
"cloud.google_assistant": false,
|
||||
},
|
||||
"switch.decorative_lights": {
|
||||
conversation: true,
|
||||
"cloud.alexa": false,
|
||||
"cloud.google_assistant": true,
|
||||
},
|
||||
"climate.ecobee": {
|
||||
conversation: true,
|
||||
"cloud.alexa": true,
|
||||
"cloud.google_assistant": true,
|
||||
},
|
||||
};
|
||||
|
||||
export const mockExpose = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("homeassistant/expose_entity/list", () => ({
|
||||
exposed_entities: exposedEntities,
|
||||
}));
|
||||
hass.mockWS(
|
||||
"homeassistant/expose_new_entities/get",
|
||||
(msg: { assistant: string }) => ({
|
||||
expose_new: msg.assistant !== "cloud.google_assistant",
|
||||
})
|
||||
);
|
||||
hass.mockWS("homeassistant/expose_entity", () => null);
|
||||
hass.mockWS("homeassistant/expose_new_entities/set", () => null);
|
||||
};
|
||||
@@ -42,6 +42,7 @@ export const mockFrontend = (hass: MockHomeAssistant) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return () => {};
|
||||
});
|
||||
hass.mockWS("frontend/get_system_data", () => ({ value: null }));
|
||||
hass.mockWS("repairs/list_issues", () => ({ issues: [] }));
|
||||
hass.mockWS("frontend/get_themes", (_msg, currentHass) => currentHass.themes);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { IntegrationManifest } from "../../../src/data/integration";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const manifest = (
|
||||
domain: string,
|
||||
name: string,
|
||||
overrides: Partial<IntegrationManifest> = {}
|
||||
): IntegrationManifest => ({
|
||||
is_built_in: true,
|
||||
domain,
|
||||
name,
|
||||
config_flow: true,
|
||||
documentation: `https://www.home-assistant.io/integrations/${domain}/`,
|
||||
iot_class: "local_push",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const manifests: IntegrationManifest[] = [
|
||||
manifest("co2signal", "Electricity Maps", { iot_class: "cloud_polling" }),
|
||||
manifest("hue", "Philips Hue"),
|
||||
manifest("sonos", "Sonos"),
|
||||
manifest("met", "Met.no", { iot_class: "cloud_polling" }),
|
||||
// Helpers
|
||||
manifest("template", "Template", { integration_type: "helper" }),
|
||||
manifest("input_boolean", "Toggle", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("input_number", "Number", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("input_select", "Dropdown", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("input_text", "Text", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("input_datetime", "Date and/or time", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("counter", "Counter", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("timer", "Timer", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("schedule", "Schedule", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
];
|
||||
|
||||
export const mockIntegration = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("manifest/list", () => manifests);
|
||||
hass.mockWS("manifest/get", (msg: { integration: string }) =>
|
||||
manifests.find((m) => m.domain === msg.integration)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { NetworkUrls } from "../../../src/data/network";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockNetwork = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"network/url",
|
||||
(): NetworkUrls => ({
|
||||
internal: "http://homeassistant.local:8123",
|
||||
external: "https://demo-instance.ui.nabu.casa",
|
||||
cloud: "https://demo-instance.ui.nabu.casa",
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Person } from "../../../src/data/person";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const storage: Person[] = [
|
||||
{
|
||||
id: "demo_user",
|
||||
name: "Demo User",
|
||||
user_id: "abcd",
|
||||
device_trackers: [],
|
||||
},
|
||||
{
|
||||
id: "anne_therese",
|
||||
name: "Anne Therese",
|
||||
device_trackers: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const mockPerson = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("person/list", () => ({ storage, config: [] as Person[] }));
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { SceneConfig } from "../../../src/data/scene";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const demoSceneConfig = (id: string): SceneConfig => ({
|
||||
id,
|
||||
name: "Demo scene",
|
||||
entities: {
|
||||
"light.bed_light": { state: "on" },
|
||||
},
|
||||
});
|
||||
|
||||
export const mockScene = (hass: MockHomeAssistant) => {
|
||||
hass.mockAPI(/config\/scene\/config\/.+/, (_hass, method, path) => {
|
||||
const id = path.split("/").pop()!;
|
||||
// GET returns the config; POST/DELETE just acknowledge.
|
||||
return method === "GET" ? demoSceneConfig(id) : {};
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { RelatedResult } from "../../../src/data/search";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockSearch = (hass: MockHomeAssistant) => {
|
||||
// The demo has no relationship graph, so report no related items.
|
||||
hass.mockWS("search/related", (): RelatedResult => ({}));
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockSystemHealth = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"system_health/info",
|
||||
(_msg, _hass, onChange?: (event: any) => void) => {
|
||||
// Defer so the consumer's unsubscribe handle is initialized first
|
||||
// (real WS events arrive asynchronously).
|
||||
setTimeout(() => {
|
||||
onChange?.({
|
||||
type: "initial",
|
||||
data: {
|
||||
homeassistant: {
|
||||
info: {
|
||||
version: "DEMO",
|
||||
installation_type: "Home Assistant OS",
|
||||
dev: false,
|
||||
hassio: true,
|
||||
docker: true,
|
||||
container_arch: "aarch64",
|
||||
user: "root",
|
||||
virtualenv: false,
|
||||
python_version: "3.13.0",
|
||||
os_name: "Linux",
|
||||
os_version: "6.6.0",
|
||||
arch: "aarch64",
|
||||
timezone: "America/Los_Angeles",
|
||||
config_dir: "/config",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,33 @@
|
||||
import type { LoggedError } from "../../../src/data/system_log";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const now = Date.now() / 1000;
|
||||
|
||||
const logs: LoggedError[] = [
|
||||
{
|
||||
name: "homeassistant.components.demo",
|
||||
message: ["Demo integration failed to update sensor data"],
|
||||
level: "warning",
|
||||
source: ["components/demo/sensor.py", 142],
|
||||
exception: "",
|
||||
count: 2,
|
||||
timestamp: now - 120,
|
||||
first_occurred: now - 3600,
|
||||
},
|
||||
{
|
||||
name: "homeassistant.config_entries",
|
||||
message: ["Config entry for met.no could not be set up"],
|
||||
level: "error",
|
||||
source: ["config_entries.py", 512],
|
||||
exception:
|
||||
'Traceback (most recent call last):\n File "config_entries.py", line 512',
|
||||
count: 1,
|
||||
timestamp: now - 600,
|
||||
first_occurred: now - 600,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockSystemLog = (hass: MockHomeAssistant) => {
|
||||
hass.mockAPI("error/all", () => []);
|
||||
hass.mockWS("system_log/list", () => logs);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Zone } from "../../../src/data/zone";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const zones: Zone[] = [
|
||||
{
|
||||
id: "home",
|
||||
name: "Home",
|
||||
icon: "mdi:home",
|
||||
latitude: 52.3731339,
|
||||
longitude: 4.8903147,
|
||||
radius: 100,
|
||||
passive: false,
|
||||
},
|
||||
{
|
||||
id: "work",
|
||||
name: "Work",
|
||||
icon: "mdi:briefcase",
|
||||
latitude: 52.3909184,
|
||||
longitude: 4.8530821,
|
||||
radius: 200,
|
||||
passive: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockZone = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("zone/list", () => zones);
|
||||
};
|
||||
@@ -62,6 +62,17 @@ Use `sidebar.js` when a page needs a visible section, section header, or determi
|
||||
- New categories without a sidebar entry are appended by the generator with their category name as the header.
|
||||
- If a listed page does not exist, the generator logs an error during `gather-gallery-pages`.
|
||||
|
||||
### Subsections
|
||||
|
||||
A section can group its pages under named subsections instead of one flat list. Use this for large categories where related pages should sit together.
|
||||
|
||||
- `subsections` is an array of `{ header, pages }`. It is mutually exclusive with a flat `pages` array on the same group.
|
||||
- Each subsection `header` is a non-collapsible label rendered inside the section's expansion panel; the section stays the only collapsible level.
|
||||
- Listed pages keep their per-subsection order.
|
||||
- Any pages found in the category but not listed in a subsection are collected into a generated `Other` subsection, appended alphabetically. The `Other` subsection is omitted when there are no leftovers.
|
||||
- A listed page that does not exist still logs an error during `gather-gallery-pages`.
|
||||
- Use sentence case for subsection headers and follow the content standards below.
|
||||
|
||||
## Markdown Pages
|
||||
|
||||
Use markdown pages for explanations, design guidance, API notes, and copy standards.
|
||||
|
||||
+164
-9
@@ -10,6 +10,10 @@ import {
|
||||
mdiViewDashboard,
|
||||
} from "@mdi/js";
|
||||
|
||||
// A group may list its pages flat in `pages`, or group them under named
|
||||
// `subsections`. The two are mutually exclusive. Listed pages keep their order;
|
||||
// any pages found in the category but not listed are appended alphabetically
|
||||
// (to a generated "Other" subsection when the group uses subsections).
|
||||
export default [
|
||||
{
|
||||
// This section has no header and so all page links are shown directly in the sidebar
|
||||
@@ -27,31 +31,162 @@ export default [
|
||||
category: "components",
|
||||
icon: mdiPuzzle,
|
||||
header: "Components",
|
||||
subsections: [
|
||||
{
|
||||
header: "Form and selectors",
|
||||
pages: [
|
||||
"ha-form",
|
||||
"ha-selector",
|
||||
"ha-select-box",
|
||||
"ha-input",
|
||||
"ha-textarea",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Controls and sliders",
|
||||
pages: [
|
||||
"ha-button",
|
||||
"ha-control-button",
|
||||
"ha-progress-button",
|
||||
"ha-switch",
|
||||
"ha-control-switch",
|
||||
"ha-slider",
|
||||
"ha-control-slider",
|
||||
"ha-control-circular-slider",
|
||||
"ha-control-number-buttons",
|
||||
"ha-control-select",
|
||||
"ha-control-select-menu",
|
||||
"ha-hs-color-picker",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Overlays",
|
||||
pages: [
|
||||
"ha-dialog",
|
||||
"ha-dialogs",
|
||||
"ha-adaptive-dialog",
|
||||
"ha-adaptive-popover",
|
||||
"ha-dropdown",
|
||||
"ha-tooltip",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Lists and disclosure",
|
||||
pages: ["ha-list", "ha-expansion-panel", "ha-faded"],
|
||||
},
|
||||
{
|
||||
header: "Feedback and status",
|
||||
pages: ["ha-alert", "ha-spinner", "ha-tip", "ha-bar", "ha-gauge"],
|
||||
},
|
||||
{
|
||||
header: "Labels and text",
|
||||
pages: ["ha-badge", "ha-label-badge", "ha-chips", "ha-marquee-text"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "lovelace",
|
||||
icon: mdiViewDashboard,
|
||||
// Label for in the sidebar
|
||||
header: "Dashboards",
|
||||
// Specify order of pages. Any pages in the category folder but not listed here will
|
||||
// automatically be added after the pages listed here.
|
||||
pages: ["introduction"],
|
||||
subsections: [
|
||||
{
|
||||
header: "Introduction",
|
||||
pages: ["introduction"],
|
||||
},
|
||||
{
|
||||
header: "Entity cards",
|
||||
pages: [
|
||||
"entities-card",
|
||||
"entity-button-card",
|
||||
"entity-filter-card",
|
||||
"glance-card",
|
||||
"tile-card",
|
||||
"area-card",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Picture cards",
|
||||
pages: [
|
||||
"picture-card",
|
||||
"picture-elements-card",
|
||||
"picture-entity-card",
|
||||
"picture-glance-card",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Domain cards",
|
||||
pages: [
|
||||
"light-card",
|
||||
"thermostat-card",
|
||||
"alarm-panel-card",
|
||||
"gauge-card",
|
||||
"plant-card",
|
||||
"map-card",
|
||||
"media-control-card",
|
||||
"media-player-row",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Layout and utility",
|
||||
pages: [
|
||||
"grid-and-stack-card",
|
||||
"conditional-card",
|
||||
"iframe-card",
|
||||
"markdown-card",
|
||||
"todo-list-card",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "more-info",
|
||||
icon: mdiInformationOutline,
|
||||
header: "More Info dialogs",
|
||||
subsections: [
|
||||
{
|
||||
header: "Climate and water",
|
||||
pages: ["climate", "humidifier", "water-heater", "fan"],
|
||||
},
|
||||
{
|
||||
header: "Covers and access",
|
||||
pages: ["cover", "lock", "lawn-mower", "vacuum"],
|
||||
},
|
||||
{
|
||||
header: "Lighting",
|
||||
pages: ["light", "scene"],
|
||||
},
|
||||
{
|
||||
header: "Media",
|
||||
pages: ["media-player"],
|
||||
},
|
||||
{
|
||||
header: "Inputs and values",
|
||||
pages: ["input-number", "input-text", "number", "timer"],
|
||||
},
|
||||
{
|
||||
header: "System",
|
||||
pages: ["update"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "automation",
|
||||
icon: mdiRobot,
|
||||
header: "Automation",
|
||||
pages: [
|
||||
"editor-trigger",
|
||||
"editor-condition",
|
||||
"editor-action",
|
||||
"trace",
|
||||
"trace-timeline",
|
||||
subsections: [
|
||||
{
|
||||
header: "Editors",
|
||||
pages: ["editor-trigger", "editor-condition", "editor-action"],
|
||||
},
|
||||
{
|
||||
header: "Descriptions",
|
||||
pages: ["describe-trigger", "describe-condition", "describe-action"],
|
||||
},
|
||||
{
|
||||
header: "Traces",
|
||||
pages: ["trace", "trace-timeline"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -64,6 +199,26 @@ export default [
|
||||
category: "date-time",
|
||||
icon: mdiCalendarClock,
|
||||
header: "Date and Time",
|
||||
subsections: [
|
||||
{
|
||||
header: "Date",
|
||||
pages: ["date"],
|
||||
},
|
||||
{
|
||||
header: "Time",
|
||||
pages: ["time", "time-seconds", "time-weekday"],
|
||||
},
|
||||
{
|
||||
header: "Combined",
|
||||
pages: [
|
||||
"date-time",
|
||||
"date-time-numeric",
|
||||
"date-time-seconds",
|
||||
"date-time-short",
|
||||
"date-time-short-year",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "misc",
|
||||
|
||||
+60
-20
@@ -40,15 +40,26 @@ interface GalleryPage {
|
||||
demo?: unknown;
|
||||
}
|
||||
|
||||
interface GallerySidebarSubsection {
|
||||
header: string;
|
||||
pages: string[];
|
||||
}
|
||||
|
||||
interface GallerySidebarGroup {
|
||||
category: string;
|
||||
header?: string;
|
||||
icon?: string;
|
||||
pages: string[];
|
||||
pages?: string[];
|
||||
subsections?: GallerySidebarSubsection[];
|
||||
}
|
||||
|
||||
const groupPages = (group: GallerySidebarGroup): string[] =>
|
||||
group.subsections
|
||||
? group.subsections.flatMap((subsection) => subsection.pages)
|
||||
: (group.pages ?? []);
|
||||
|
||||
const GALLERY_SIDEBAR = SIDEBAR as GallerySidebarGroup[];
|
||||
const DEFAULT_PAGE = `${GALLERY_SIDEBAR[0].category}/${GALLERY_SIDEBAR[0].pages[0]}`;
|
||||
const DEFAULT_PAGE = `${GALLERY_SIDEBAR[0].category}/${groupPages(GALLERY_SIDEBAR[0])[0]}`;
|
||||
|
||||
const mql = matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
@@ -284,26 +295,15 @@ class HaGallery extends LitElement {
|
||||
const sidebar: unknown[] = [];
|
||||
|
||||
for (const group of GALLERY_SIDEBAR) {
|
||||
const links: unknown[] = [];
|
||||
const expanded = group.pages.some(
|
||||
const expanded = groupPages(group).some(
|
||||
(page) => this._page === `${group.category}/${page}`
|
||||
);
|
||||
|
||||
for (const page of group.pages) {
|
||||
const key = `${group.category}/${page}`;
|
||||
if (!(key in PAGES)) {
|
||||
console.error("Undefined page referenced in sidebar.js:", key);
|
||||
continue;
|
||||
}
|
||||
links.push(
|
||||
this._renderPageLink(
|
||||
key,
|
||||
PAGES[key].metadata.title || page,
|
||||
group.header ? undefined : "main-navigation",
|
||||
group.header ? undefined : group.icon
|
||||
const content = group.subsections
|
||||
? group.subsections.map((subsection) =>
|
||||
this._renderSidebarSubsection(group, subsection)
|
||||
)
|
||||
);
|
||||
}
|
||||
: this._renderPageLinks(group, group.pages ?? []);
|
||||
|
||||
sidebar.push(
|
||||
group.header
|
||||
@@ -321,16 +321,46 @@ class HaGallery extends LitElement {
|
||||
.path=${group.icon}
|
||||
></ha-svg-icon>`
|
||||
: nothing}
|
||||
${links}
|
||||
${content}
|
||||
</ha-expansion-panel>
|
||||
`
|
||||
: links
|
||||
: content
|
||||
);
|
||||
}
|
||||
|
||||
return sidebar;
|
||||
}
|
||||
|
||||
private _renderSidebarSubsection(
|
||||
group: GallerySidebarGroup,
|
||||
subsection: GallerySidebarSubsection
|
||||
) {
|
||||
return html`
|
||||
<div class="gallery-sidebar-subheader">${subsection.header}</div>
|
||||
${this._renderPageLinks(group, subsection.pages)}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderPageLinks(group: GallerySidebarGroup, pages: string[]) {
|
||||
const links: unknown[] = [];
|
||||
for (const page of pages) {
|
||||
const key = `${group.category}/${page}`;
|
||||
if (!(key in PAGES)) {
|
||||
console.error("Undefined page referenced in sidebar.js:", key);
|
||||
continue;
|
||||
}
|
||||
links.push(
|
||||
this._renderPageLink(
|
||||
key,
|
||||
PAGES[key].metadata.title || page,
|
||||
group.header ? undefined : "main-navigation",
|
||||
group.header ? undefined : group.icon
|
||||
)
|
||||
);
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
private _renderPageLink(
|
||||
page: string,
|
||||
title: string,
|
||||
@@ -585,6 +615,16 @@ class HaGallery extends LitElement {
|
||||
width: var(--ha-sidebar-expanded-section-item-width, 248px);
|
||||
}
|
||||
|
||||
.gallery-sidebar-subheader {
|
||||
margin: var(--ha-space-2) var(--ha-space-4) var(--ha-space-1);
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.gallery-sidebar-icon,
|
||||
.gallery-nav-item ha-svg-icon[slot="start"] {
|
||||
color: var(--sidebar-icon-color);
|
||||
|
||||
@@ -4,21 +4,30 @@ title: Home
|
||||
|
||||
# Welcome to Home Assistant Design
|
||||
|
||||
This portal aims to aid designers and developers on improving the Home Assistant interface. It consists of working code, resources and guidelines.
|
||||
This is the design gallery for the Home Assistant frontend: a living reference of working components, dashboard cards, and brand and copy guidance. Every page runs outside a Home Assistant instance, so you can explore the interface, try components in isolation, and review changes against a consistent baseline.
|
||||
|
||||
## Home Assistant interface
|
||||
## Browse the gallery
|
||||
|
||||
The Home Assistant frontend allows users to browse and control the state of their home, manage their automations and configure integrations. The frontend is designed as a mobile-first experience. It is a progressive web application and offers an app-like experience to our users. The Home Assistant frontend needs to be fast. But it also needs to work on a wide range of old devices.
|
||||
- [Brand](#brand/logo): the logo, personality, and the story behind the Open Home.
|
||||
- [Components](#components/ha-button): the `ha-*` component library with live demos and API notes.
|
||||
- [Dashboards](#lovelace/introduction): Lovelace cards rendered from real card configuration.
|
||||
- [More Info dialogs](#more-info/light): the more-info experience for each entity type.
|
||||
- [Automation](#automation/editor-trigger): trigger, condition, and action editors, plus trace views.
|
||||
- [Users](#user-test/user-types): the audiences we design for.
|
||||
- [Date and time](#date-time/date): date and time formatting examples.
|
||||
- [Miscellaneous](#misc/entity-state): smaller utilities and patterns, plus how to edit this gallery.
|
||||
|
||||
### Material Design
|
||||
## Testing and playground
|
||||
|
||||
The Home Assistant interface is based on Material Design. It's a design system created by Google to quickly build high-quality digital experiences. Components and guidelines that are custom made for Home Assistant are documented on this portal. For all other components check <a href="https://material.io" rel="noopener noreferrer" target="_blank">material.io</a>.
|
||||
Every page runs against fake state, so you can interact with components safely and reproducibly. Treat the demo pages as a playground: change a value, resize the window, or switch the layout to right-to-left to check spacing and direction. Use the gallery to reproduce a UI state in isolation before debugging it in a full Home Assistant setup.
|
||||
|
||||
Open **Settings** from the gear icon in the sidebar to switch between light and dark themes or preview the interface in right-to-left.
|
||||
|
||||
## Designers
|
||||
|
||||
We want to make it as easy for designers to contribute as it is for developers. There’s a lot a designer can contribute to:
|
||||
We want to make it as easy for designers to contribute as it is for developers. There's a lot a designer can contribute to:
|
||||
|
||||
- Meet us at <a href="https://www.home-assistant.io/join-chat-design" rel="noopener noreferrer" target="_blank">Discord #designers channel</a>. If you can't see the channel, make sure you set the correct role in Channels & Roles.
|
||||
- Meet us in the <a href="https://www.home-assistant.io/join-chat-design" rel="noopener noreferrer" target="_blank">Discord #designers channel</a>. If you can't see the channel, make sure you set the correct role in Channels & Roles.
|
||||
- Start designing with our <a href="https://www.figma.com/design/2WGI8IDGyxINjSV6NRvPur/Home-Assistant-Design-Kit" rel="noopener noreferrer" target="_blank">Figma DesignKit</a>.
|
||||
- Find the latest UX <a href="https://github.com/home-assistant/frontend/discussions?discussions_q=label%3Aux" rel="noopener noreferrer" target="_blank">discussions</a> and <a href="https://github.com/home-assistant/frontend/labels/ux" rel="noopener noreferrer" target="_blank">issues</a> on GitHub. Everyone can start a new issue or discussion!
|
||||
|
||||
|
||||
@@ -372,7 +372,6 @@ export class DemoEntityState extends LitElement {
|
||||
hass.localize,
|
||||
entry.stateObj,
|
||||
hass.locale,
|
||||
[], // numericDeviceClasses
|
||||
hass.config,
|
||||
hass.entities
|
||||
)}`,
|
||||
|
||||
+10
-9
@@ -21,6 +21,7 @@
|
||||
"prepack": "pinst --disable",
|
||||
"postpack": "pinst --enable",
|
||||
"test": "vitest run --config test/vitest.config.ts",
|
||||
"test:bench": "vitest bench --run --config test/vitest.bench.config.ts",
|
||||
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
|
||||
},
|
||||
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
|
||||
@@ -34,13 +35,13 @@
|
||||
"@codemirror/lang-jinja": "6.0.1",
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.12.3",
|
||||
"@codemirror/lint": "6.9.6",
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.0",
|
||||
"@codemirror/state": "6.6.0",
|
||||
"@codemirror/view": "6.43.0",
|
||||
"@codemirror/view": "6.43.1",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.4.8",
|
||||
"@formatjs/intl-datetimeformat": "7.4.9",
|
||||
"@formatjs/intl-displaynames": "7.3.10",
|
||||
"@formatjs/intl-durationformat": "0.10.14",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.10",
|
||||
@@ -131,13 +132,13 @@
|
||||
"@babel/preset-env": "7.29.7",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.2",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@html-eslint/eslint-plugin": "0.61.0",
|
||||
"@html-eslint/eslint-plugin": "0.62.0",
|
||||
"@lokalise/node-api": "16.0.0",
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.12",
|
||||
"@rspack/core": "2.0.6",
|
||||
"@rsdoctor/rspack-plugin": "1.5.13",
|
||||
"@rspack/core": "2.0.8",
|
||||
"@rspack/dev-server": "2.0.3",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
@@ -158,7 +159,7 @@
|
||||
"babel-plugin-template-html-minifier": "4.1.0",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.4.1",
|
||||
"eslint": "10.5.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.16.2",
|
||||
@@ -186,7 +187,7 @@
|
||||
"lodash.template": "4.18.1",
|
||||
"map-stream": "0.0.7",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.8.3",
|
||||
"prettier": "3.8.4",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
"serve": "14.2.6",
|
||||
"sinon": "22.0.0",
|
||||
@@ -194,7 +195,7 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.60.1",
|
||||
"typescript-eslint": "8.61.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.8",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Safe bash settings
|
||||
# -e Exit on command fail
|
||||
# -u Exit on unset variable
|
||||
# -o pipefail Exit if piped command has error code
|
||||
set -eu -o pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
./node_modules/.bin/gulp gen-numeric-device-classes
|
||||
@@ -1,7 +1,7 @@
|
||||
// Load a resource and get a promise when loading done.
|
||||
// From: https://davidwalsh.name/javascript-loader
|
||||
|
||||
const _load = (tag: "link" | "script" | "img", url: string, type?: "module") =>
|
||||
const _load = (tag: "link" | "script", url: string, type?: "module") =>
|
||||
// This promise will be used by Promise.all to determine success or failure
|
||||
new Promise((resolve, reject) => {
|
||||
const element = document.createElement(tag);
|
||||
@@ -33,5 +33,4 @@ const _load = (tag: "link" | "script" | "img", url: string, type?: "module") =>
|
||||
});
|
||||
export const loadCSS = (url: string) => _load("link", url);
|
||||
export const loadJS = (url: string) => _load("script", url);
|
||||
export const loadImg = (url: string) => _load("img", url);
|
||||
export const loadModule = (url: string) => _load("script", url, "module");
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* Scroll to a specific y coordinate.
|
||||
*
|
||||
* Copied from paper-scroll-header-panel.
|
||||
*
|
||||
* @method scroll
|
||||
* @param {number} top The coordinate to scroll to, along the y-axis.
|
||||
* @param {boolean} smooth true if the scroll position should be smoothly adjusted.
|
||||
*/
|
||||
export default function scrollToTarget(element, target) {
|
||||
// the scroll event will trigger _updateScrollState directly,
|
||||
// However, _updateScrollState relies on the previous `scrollTop` to update the states.
|
||||
// Calling _updateScrollState will ensure that the states are synced correctly.
|
||||
const top = 0;
|
||||
const scroller = target;
|
||||
const easingFn = function easeOutQuad(t, b, c, d) {
|
||||
t /= d;
|
||||
return -c * t * (t - 2) + b;
|
||||
};
|
||||
const animationId = Math.random();
|
||||
const duration = 200;
|
||||
const startTime = Date.now();
|
||||
const currentScrollTop = scroller.scrollTop;
|
||||
const deltaScrollTop = top - currentScrollTop;
|
||||
element._currentAnimationId = animationId;
|
||||
(function updateFrame() {
|
||||
const now = Date.now();
|
||||
const elapsedTime = now - startTime;
|
||||
if (elapsedTime > duration) {
|
||||
scroller.scrollTop = top;
|
||||
} else if (element._currentAnimationId === animationId) {
|
||||
scroller.scrollTop = easingFn(
|
||||
elapsedTime,
|
||||
currentScrollTop,
|
||||
deltaScrollTop,
|
||||
duration
|
||||
);
|
||||
requestAnimationFrame(updateFrame.bind(element));
|
||||
}
|
||||
}).call(element);
|
||||
}
|
||||
@@ -3,8 +3,6 @@ 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");
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
export type LeafletDrawModuleType = typeof import("leaflet-draw");
|
||||
|
||||
export const setupLeafletMap = async (
|
||||
mapElement: HTMLElement,
|
||||
@@ -45,17 +43,6 @@ export const setupLeafletMap = async (
|
||||
return [map, Leaflet, tileLayer];
|
||||
};
|
||||
|
||||
export const replaceTileLayer = (
|
||||
leaflet: LeafletModuleType,
|
||||
map: Map,
|
||||
tileLayer: TileLayer
|
||||
): TileLayer => {
|
||||
map.removeLayer(tileLayer);
|
||||
tileLayer = createTileLayer(leaflet);
|
||||
tileLayer.addTo(map);
|
||||
return tileLayer;
|
||||
};
|
||||
|
||||
const createTileLayer = (leaflet: LeafletModuleType): TileLayer =>
|
||||
leaflet.tileLayer(
|
||||
`https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}${
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
/** An empty image which can be set as src of an img element. */
|
||||
export const emptyImageBase64 =
|
||||
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
@@ -17,13 +17,51 @@ import {
|
||||
import { blankBeforeUnit } from "../translations/blank_before_unit";
|
||||
import type { LocalizeFunc } from "../translations/localize";
|
||||
import { computeDomain } from "./compute_domain";
|
||||
import { SENSOR_TIMESTAMP_DEVICE_CLASSES } from "../../data/sensor";
|
||||
import {
|
||||
isNumericSensorDeviceClass,
|
||||
SENSOR_TIMESTAMP_DEVICE_CLASSES,
|
||||
} from "../../data/sensor";
|
||||
|
||||
// Domains whose state is a timezone-agnostic date and/or time string.
|
||||
const DATE_TIME_DOMAINS = new Set(["date", "input_datetime", "time"]);
|
||||
|
||||
// Domains whose state is a timestamp.
|
||||
const TIMESTAMP_DOMAINS = new Set([
|
||||
"ai_task",
|
||||
"button",
|
||||
"conversation",
|
||||
"event",
|
||||
"image",
|
||||
"infrared",
|
||||
"input_button",
|
||||
"notify",
|
||||
"radio_frequency",
|
||||
"scene",
|
||||
"stt",
|
||||
"tag",
|
||||
"tts",
|
||||
"wake_word",
|
||||
"datetime",
|
||||
]);
|
||||
|
||||
// Maps Intl.NumberFormat part types to ValuePart types for monetary states.
|
||||
const MONETARY_TYPE_MAP: Record<string, ValuePart["type"]> = {
|
||||
integer: "value",
|
||||
group: "value",
|
||||
decimal: "value",
|
||||
fraction: "value",
|
||||
minusSign: "value",
|
||||
plusSign: "value",
|
||||
literal: "literal",
|
||||
currency: "unit",
|
||||
};
|
||||
|
||||
const NUMERICAL_DOMAINS = ["counter", "input_number", "number"];
|
||||
|
||||
export const computeStateDisplay = (
|
||||
localize: LocalizeFunc,
|
||||
stateObj: HassEntity,
|
||||
locale: FrontendLocaleData,
|
||||
sensorNumericDeviceClasses: string[],
|
||||
config: HassConfig,
|
||||
entities: HomeAssistant["entities"],
|
||||
state?: string
|
||||
@@ -34,7 +72,6 @@ export const computeStateDisplay = (
|
||||
return computeStateDisplayFromEntityAttributes(
|
||||
localize,
|
||||
locale,
|
||||
sensorNumericDeviceClasses,
|
||||
config,
|
||||
entity,
|
||||
stateObj.entity_id,
|
||||
@@ -46,7 +83,6 @@ export const computeStateDisplay = (
|
||||
export const computeStateDisplayFromEntityAttributes = (
|
||||
localize: LocalizeFunc,
|
||||
locale: FrontendLocaleData,
|
||||
sensorNumericDeviceClasses: string[],
|
||||
config: HassConfig,
|
||||
entity: EntityRegistryDisplayEntry | undefined,
|
||||
entityId: string,
|
||||
@@ -56,7 +92,6 @@ export const computeStateDisplayFromEntityAttributes = (
|
||||
const parts = computeStateToPartsFromEntityAttributes(
|
||||
localize,
|
||||
locale,
|
||||
sensorNumericDeviceClasses,
|
||||
config,
|
||||
entity,
|
||||
entityId,
|
||||
@@ -69,7 +104,6 @@ export const computeStateDisplayFromEntityAttributes = (
|
||||
const computeStateToPartsFromEntityAttributes = (
|
||||
localize: LocalizeFunc,
|
||||
locale: FrontendLocaleData,
|
||||
sensorNumericDeviceClasses: string[],
|
||||
config: HassConfig,
|
||||
entity: EntityRegistryDisplayEntry | undefined,
|
||||
entityId: string,
|
||||
@@ -86,15 +120,15 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
}
|
||||
|
||||
const domain = computeDomain(entityId);
|
||||
const is_number_domain =
|
||||
domain === "counter" || domain === "number" || domain === "input_number";
|
||||
// Entities with a `unit_of_measurement` or `state_class` are numeric values and should use `formatNumber`
|
||||
const isNumberDomain = NUMERICAL_DOMAINS.includes(domain);
|
||||
const isSensorDomain = domain === "sensor";
|
||||
|
||||
// Numeric values (by attributes, number domain,
|
||||
// or numeric sensor device class) use formatNumber.
|
||||
if (
|
||||
isNumericFromAttributes(
|
||||
attributes,
|
||||
domain === "sensor" ? sensorNumericDeviceClasses : []
|
||||
) ||
|
||||
is_number_domain
|
||||
isNumericFromAttributes(attributes) ||
|
||||
isNumberDomain ||
|
||||
(isSensorDomain && isNumericSensorDeviceClass(attributes.device_class))
|
||||
) {
|
||||
// state is duration
|
||||
if (
|
||||
@@ -138,21 +172,10 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
}
|
||||
|
||||
if (parts.length) {
|
||||
const TYPE_MAP: Record<string, ValuePart["type"]> = {
|
||||
integer: "value",
|
||||
group: "value",
|
||||
decimal: "value",
|
||||
fraction: "value",
|
||||
minusSign: "value",
|
||||
plusSign: "value",
|
||||
literal: "literal",
|
||||
currency: "unit",
|
||||
};
|
||||
|
||||
const valueParts: ValuePart[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
const type = TYPE_MAP[part.type];
|
||||
const type = MONETARY_TYPE_MAP[part.type];
|
||||
if (!type) continue;
|
||||
const last = valueParts[valueParts.length - 1];
|
||||
// Merge consecutive value parts (e.g. "-" + "12" + "." + "00" → "-12.00")
|
||||
@@ -191,7 +214,7 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
return [{ type: "value", value: value }];
|
||||
}
|
||||
|
||||
if (["date", "input_datetime", "time"].includes(domain)) {
|
||||
if (DATE_TIME_DOMAINS.has(domain)) {
|
||||
// If trying to display an explicit state, need to parse the explicit state to `Date` then format.
|
||||
// Attributes aren't available, we have to use `state`.
|
||||
|
||||
@@ -250,23 +273,7 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
|
||||
// state is a timestamp
|
||||
if (
|
||||
[
|
||||
"ai_task",
|
||||
"button",
|
||||
"conversation",
|
||||
"event",
|
||||
"image",
|
||||
"infrared",
|
||||
"input_button",
|
||||
"notify",
|
||||
"radio_frequency",
|
||||
"scene",
|
||||
"stt",
|
||||
"tag",
|
||||
"tts",
|
||||
"wake_word",
|
||||
"datetime",
|
||||
].includes(domain) ||
|
||||
TIMESTAMP_DOMAINS.has(domain) ||
|
||||
(domain === "sensor" &&
|
||||
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(attributes.device_class))
|
||||
) {
|
||||
@@ -307,7 +314,6 @@ export const computeStateToParts = (
|
||||
localize: LocalizeFunc,
|
||||
stateObj: HassEntity,
|
||||
locale: FrontendLocaleData,
|
||||
sensorNumericDeviceClasses: string[],
|
||||
config: HassConfig,
|
||||
entities: HomeAssistant["entities"],
|
||||
state?: string
|
||||
@@ -318,7 +324,6 @@ export const computeStateToParts = (
|
||||
return computeStateToPartsFromEntityAttributes(
|
||||
localize,
|
||||
locale,
|
||||
sensorNumericDeviceClasses,
|
||||
config,
|
||||
entity,
|
||||
stateObj.entity_id,
|
||||
|
||||
@@ -4,9 +4,10 @@ import { updateIsInstalling } from "../../data/update";
|
||||
|
||||
export const updateIcon = (stateObj: HassEntity, state?: string) => {
|
||||
const compareState = state ?? stateObj.state;
|
||||
return compareState === "on"
|
||||
? updateIsInstalling(stateObj as UpdateEntity)
|
||||
? "mdi:package-down"
|
||||
: "mdi:package-up"
|
||||
: "mdi:package";
|
||||
// An install can be in progress even when the state is "off", e.g. when
|
||||
// downgrading firmware. Show the installing icon regardless of state.
|
||||
if (updateIsInstalling(stateObj as UpdateEntity)) {
|
||||
return "mdi:package-down";
|
||||
}
|
||||
return compareState === "on" ? "mdi:package-up" : "mdi:package";
|
||||
};
|
||||
|
||||
@@ -14,12 +14,8 @@ export const isNumericState = (stateObj: HassEntity): boolean =>
|
||||
isNumericFromAttributes(stateObj.attributes);
|
||||
|
||||
export const isNumericFromAttributes = (
|
||||
attributes: HassEntityAttributeBase,
|
||||
numericDeviceClasses?: string[]
|
||||
): boolean =>
|
||||
!!attributes.unit_of_measurement ||
|
||||
!!attributes.state_class ||
|
||||
(numericDeviceClasses || []).includes(attributes.device_class || "");
|
||||
attributes: HassEntityAttributeBase
|
||||
): boolean => !!attributes.unit_of_measurement || !!attributes.state_class;
|
||||
|
||||
export const numberFormatToLocale = (
|
||||
localeOptions: FrontendLocaleData
|
||||
@@ -40,6 +36,25 @@ export const numberFormatToLocale = (
|
||||
}
|
||||
};
|
||||
|
||||
// Constructing an Intl.NumberFormat is comparatively expensive, and these
|
||||
// formatters are created on every numeric state render. The number of distinct
|
||||
// (locale, options) combinations is small and bounded in practice, so cache the
|
||||
// instances instead of rebuilding them on every call.
|
||||
const numberFormatCache = new Map<string, Intl.NumberFormat>();
|
||||
|
||||
const getNumberFormatter = (
|
||||
locale: string | string[] | undefined,
|
||||
options: Intl.NumberFormatOptions
|
||||
): Intl.NumberFormat => {
|
||||
const key = JSON.stringify([locale, options]);
|
||||
let formatter = numberFormatCache.get(key);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.NumberFormat(locale, options);
|
||||
numberFormatCache.set(key, formatter);
|
||||
}
|
||||
return formatter;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a number based on the user's preference with thousands separator(s) and decimal character for better legibility.
|
||||
*
|
||||
@@ -75,7 +90,7 @@ export const formatNumberToParts = (
|
||||
localeOptions?.number_format !== NumberFormat.none &&
|
||||
!Number.isNaN(Number(num))
|
||||
) {
|
||||
return new Intl.NumberFormat(
|
||||
return getNumberFormatter(
|
||||
locale,
|
||||
getDefaultFormatOptions(num, options)
|
||||
).formatToParts(Number(num));
|
||||
@@ -87,7 +102,7 @@ export const formatNumberToParts = (
|
||||
localeOptions?.number_format === NumberFormat.none
|
||||
) {
|
||||
// If NumberFormat is none, use en-US format without grouping.
|
||||
return new Intl.NumberFormat(
|
||||
return getNumberFormatter(
|
||||
"en-US",
|
||||
getDefaultFormatOptions(num, {
|
||||
...options,
|
||||
|
||||
@@ -46,8 +46,7 @@ export const computeFormatFunctions = async (
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"],
|
||||
sensorNumericDeviceClasses: string[]
|
||||
floors: HomeAssistant["floors"]
|
||||
): Promise<{
|
||||
formatEntityState: FormatEntityStateFunc;
|
||||
formatEntityStateToParts: FormatEntityStateToPartsFunc;
|
||||
@@ -66,25 +65,9 @@ export const computeFormatFunctions = async (
|
||||
|
||||
return {
|
||||
formatEntityState: (stateObj, state) =>
|
||||
computeStateDisplay(
|
||||
localize,
|
||||
stateObj,
|
||||
locale,
|
||||
sensorNumericDeviceClasses,
|
||||
config,
|
||||
entities,
|
||||
state
|
||||
),
|
||||
computeStateDisplay(localize, stateObj, locale, config, entities, state),
|
||||
formatEntityStateToParts: (stateObj, state) =>
|
||||
computeStateToParts(
|
||||
localize,
|
||||
stateObj,
|
||||
locale,
|
||||
sensorNumericDeviceClasses,
|
||||
config,
|
||||
entities,
|
||||
state
|
||||
),
|
||||
computeStateToParts(localize, stateObj, locale, config, entities, state),
|
||||
formatEntityAttributeValue: (stateObj, attribute, value) =>
|
||||
computeAttributeValueDisplay(
|
||||
localize,
|
||||
|
||||
@@ -16,32 +16,17 @@ export type HistoryLogbookTargetParamKey =
|
||||
| "area_id"
|
||||
| "device_id";
|
||||
|
||||
export type HistoryLogbookDateParamKey = "start_date" | "end_date";
|
||||
|
||||
export type HistoryLogbookBooleanParamKey = "back";
|
||||
|
||||
export type HistoryLogbookQueryParams = QueryParamValues<
|
||||
HistoryLogbookTargetParamKey,
|
||||
HistoryLogbookDateParamKey,
|
||||
HistoryLogbookBooleanParamKey
|
||||
>;
|
||||
|
||||
export const historyLogbookTargetParamKeys: HistoryLogbookTargetParamKey[] = [
|
||||
"entity_id",
|
||||
"label_id",
|
||||
"floor_id",
|
||||
"area_id",
|
||||
"device_id",
|
||||
];
|
||||
export const historyLogbookTargetParamKeys: readonly HistoryLogbookTargetParamKey[] =
|
||||
["entity_id", "label_id", "floor_id", "area_id", "device_id"];
|
||||
|
||||
export const historyLogbookQueryParamConfig = {
|
||||
list: historyLogbookTargetParamKeys,
|
||||
date: ["start_date", "end_date"],
|
||||
boolean: [{ key: "back", trueValue: "1" }],
|
||||
} satisfies QueryParamConfig<
|
||||
HistoryLogbookTargetParamKey,
|
||||
HistoryLogbookDateParamKey,
|
||||
HistoryLogbookBooleanParamKey
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type HistoryLogbookQueryParams = QueryParamValues<
|
||||
typeof historyLogbookQueryParamConfig
|
||||
>;
|
||||
|
||||
export const decodeHistoryLogbookQueryParams = (
|
||||
|
||||
@@ -6,29 +6,49 @@ export type SearchParamsSource =
|
||||
| Record<string, string>
|
||||
| string;
|
||||
|
||||
export interface QueryParamConfig<
|
||||
ListKey extends string,
|
||||
DateKey extends string,
|
||||
BooleanKey extends string,
|
||||
> {
|
||||
list?: readonly ListKey[];
|
||||
date?: readonly DateKey[];
|
||||
export interface QueryParamConfig {
|
||||
list?: readonly string[];
|
||||
date?: readonly string[];
|
||||
boolean?: readonly {
|
||||
key: BooleanKey;
|
||||
key: string;
|
||||
trueValue: string;
|
||||
}[];
|
||||
string?: readonly string[];
|
||||
}
|
||||
|
||||
export type QueryParamValues<
|
||||
ListKey extends string,
|
||||
DateKey extends string,
|
||||
BooleanKey extends string,
|
||||
> = Partial<
|
||||
Record<ListKey, string[]> &
|
||||
Record<DateKey, Date> &
|
||||
Record<BooleanKey, boolean>
|
||||
type ListKeyOf<C extends QueryParamConfig> = C extends {
|
||||
list: readonly (infer K extends string)[];
|
||||
}
|
||||
? K
|
||||
: never;
|
||||
|
||||
type DateKeyOf<C extends QueryParamConfig> = C extends {
|
||||
date: readonly (infer K extends string)[];
|
||||
}
|
||||
? K
|
||||
: never;
|
||||
|
||||
type BooleanKeyOf<C extends QueryParamConfig> = C extends {
|
||||
boolean: readonly { key: infer K extends string }[];
|
||||
}
|
||||
? K
|
||||
: never;
|
||||
|
||||
type StringKeyOf<C extends QueryParamConfig> = C extends {
|
||||
string: readonly (infer K extends string)[];
|
||||
}
|
||||
? K
|
||||
: never;
|
||||
|
||||
export type QueryParamValues<C extends QueryParamConfig> = Partial<
|
||||
Record<ListKeyOf<C>, string[]> &
|
||||
Record<DateKeyOf<C>, Date> &
|
||||
Record<BooleanKeyOf<C>, boolean> &
|
||||
Record<StringKeyOf<C>, string>
|
||||
>;
|
||||
|
||||
type QueryParamValue = string[] | Date | boolean | string;
|
||||
|
||||
export type ServiceTargetQueryParams<
|
||||
Key extends keyof HassServiceTarget & string,
|
||||
> = Partial<Record<Key, string[]>>;
|
||||
@@ -46,53 +66,59 @@ const getSearchParam = (
|
||||
return searchParams[key] ?? null;
|
||||
};
|
||||
|
||||
export const decodeQueryParams = <
|
||||
ListKey extends string,
|
||||
DateKey extends string,
|
||||
BooleanKey extends string,
|
||||
>(
|
||||
export function decodeQueryParams<C extends QueryParamConfig>(
|
||||
searchParams: SearchParamsSource,
|
||||
config: QueryParamConfig<ListKey, DateKey, BooleanKey>
|
||||
): QueryParamValues<ListKey, DateKey, BooleanKey> => {
|
||||
const params: QueryParamValues<ListKey, DateKey, BooleanKey> = {};
|
||||
config: C
|
||||
): QueryParamValues<C>;
|
||||
export function decodeQueryParams(
|
||||
searchParams: SearchParamsSource,
|
||||
config: QueryParamConfig
|
||||
): Record<string, QueryParamValue | undefined> {
|
||||
const params: Record<string, QueryParamValue> = {};
|
||||
for (const key of config.list ?? []) {
|
||||
const value = getSearchParam(searchParams, key);
|
||||
if (value) {
|
||||
params[key] = value.split(",") as (typeof params)[typeof key];
|
||||
params[key] = value.split(",");
|
||||
}
|
||||
}
|
||||
for (const key of config.date ?? []) {
|
||||
const value = getSearchParam(searchParams, key);
|
||||
if (value) {
|
||||
params[key] = new Date(value) as (typeof params)[typeof key];
|
||||
params[key] = new Date(value);
|
||||
}
|
||||
}
|
||||
for (const { key, trueValue } of config.boolean ?? []) {
|
||||
if (getSearchParam(searchParams, key) === trueValue) {
|
||||
params[key] = true as (typeof params)[typeof key];
|
||||
params[key] = true;
|
||||
}
|
||||
}
|
||||
for (const key of config.string ?? []) {
|
||||
const value = getSearchParam(searchParams, key);
|
||||
if (value) {
|
||||
params[key] = value;
|
||||
}
|
||||
}
|
||||
return params;
|
||||
};
|
||||
}
|
||||
|
||||
export const createQueryString = <
|
||||
ListKey extends string,
|
||||
DateKey extends string,
|
||||
BooleanKey extends string,
|
||||
>(
|
||||
values: QueryParamValues<ListKey, DateKey, BooleanKey>,
|
||||
config: QueryParamConfig<ListKey, DateKey, BooleanKey>
|
||||
): string => {
|
||||
export function createQueryString<C extends QueryParamConfig>(
|
||||
values: QueryParamValues<NoInfer<C>>,
|
||||
config: C
|
||||
): string;
|
||||
export function createQueryString(
|
||||
values: Record<string, QueryParamValue | undefined>,
|
||||
config: QueryParamConfig
|
||||
): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const key of config.list ?? []) {
|
||||
const value = values[key] as string[] | undefined;
|
||||
if (value?.length) {
|
||||
const value = values[key];
|
||||
if (Array.isArray(value) && value.length) {
|
||||
searchParams.append(key, value.join(","));
|
||||
}
|
||||
}
|
||||
for (const key of config.date ?? []) {
|
||||
const value = values[key] as Date | undefined;
|
||||
if (value) {
|
||||
const value = values[key];
|
||||
if (value instanceof Date) {
|
||||
searchParams.append(key, value.toISOString());
|
||||
}
|
||||
}
|
||||
@@ -101,8 +127,14 @@ export const createQueryString = <
|
||||
searchParams.append(key, trueValue);
|
||||
}
|
||||
}
|
||||
for (const key of config.string ?? []) {
|
||||
const value = values[key];
|
||||
if (typeof value === "string" && value) {
|
||||
searchParams.append(key, value);
|
||||
}
|
||||
}
|
||||
return searchParams.toString();
|
||||
};
|
||||
}
|
||||
|
||||
export const serviceTargetFromQueryParams = <
|
||||
Key extends keyof HassServiceTarget & string,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
createQueryString,
|
||||
decodeQueryParams,
|
||||
type QueryParamConfig,
|
||||
type QueryParamValues,
|
||||
type SearchParamsSource,
|
||||
} from "./query-params";
|
||||
|
||||
export const todoQueryParamConfig = {
|
||||
string: ["entity_id"],
|
||||
boolean: [{ key: "add_item", trueValue: "true" }],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type TodoQueryParams = QueryParamValues<typeof todoQueryParamConfig>;
|
||||
|
||||
export const decodeTodoQueryParams = (
|
||||
searchParams: SearchParamsSource
|
||||
): TodoQueryParams => decodeQueryParams(searchParams, todoQueryParamConfig);
|
||||
|
||||
export const createTodoQueryString = (values: TodoQueryParams): string =>
|
||||
createQueryString(values, todoQueryParamConfig);
|
||||
@@ -11,6 +11,12 @@ export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
|
||||
}
|
||||
|
||||
const root = rootEl || deepActiveElement()?.getRootNode() || document.body;
|
||||
// A document node cannot have a textarea appended directly (only the single
|
||||
// documentElement is allowed), so fall back to its body. Shadow roots and
|
||||
// elements can hold the textarea directly, which keeps execCommand working
|
||||
// inside dialogs that trap focus.
|
||||
const container: Node =
|
||||
root.nodeType === Node.DOCUMENT_NODE ? document.body : root;
|
||||
|
||||
const el = document.createElement("textarea");
|
||||
el.value = str;
|
||||
@@ -19,8 +25,8 @@ export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
|
||||
el.style.top = "0";
|
||||
el.style.left = "0";
|
||||
el.style.opacity = "0";
|
||||
root.appendChild(el);
|
||||
container.appendChild(el);
|
||||
el.select();
|
||||
document.execCommand("copy");
|
||||
root.removeChild(el);
|
||||
container.removeChild(el);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Records like the entity, device, area and floor registries are re-fetched and
|
||||
* rebuilt in full on every registry-updated event, producing brand-new objects
|
||||
* for every item even when nothing relevant changed. That gives every item a new
|
||||
* reference, so all consumers needlessly re-render.
|
||||
*
|
||||
* Returns `next` with each item replaced by the equal `previous` item, so
|
||||
* unchanged items keep their object identity, and returns the `previous` record
|
||||
* untouched when nothing changed at all (so the update can be skipped entirely).
|
||||
*/
|
||||
export const preserveUnchangedRecord = <T>(
|
||||
previous: Record<string, T> | undefined,
|
||||
next: Record<string, T>,
|
||||
equal: (a: T, b: T) => boolean
|
||||
): Record<string, T> => {
|
||||
if (!previous) {
|
||||
return next;
|
||||
}
|
||||
let changed = Object.keys(previous).length !== Object.keys(next).length;
|
||||
for (const key of Object.keys(next)) {
|
||||
const previousItem = previous[key];
|
||||
if (previousItem !== undefined && equal(previousItem, next[key])) {
|
||||
next[key] = previousItem;
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : previous;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { ReactiveControllerHost } from "lit";
|
||||
import { clamp } from "../number/clamp";
|
||||
|
||||
// Count columns from the container's real width (not the viewport) so a
|
||||
// docked sidebar is accounted for, like the dashboard sections view.
|
||||
const MIN_COLUMN_WIDTH = 320;
|
||||
const DEFAULT_COLUMN_GAP = 16;
|
||||
|
||||
const parsePx = (value: string) => parseInt(value, 10) || 0;
|
||||
|
||||
export const createColumnsController = (
|
||||
host: ReactiveControllerHost & Element,
|
||||
maxColumns: number
|
||||
) =>
|
||||
new ResizeController<number>(host, {
|
||||
target: null,
|
||||
skipInitial: true,
|
||||
callback: (entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) {
|
||||
return maxColumns;
|
||||
}
|
||||
const width = entry.contentRect.width;
|
||||
const gap =
|
||||
parsePx(getComputedStyle(entry.target).columnGap) || DEFAULT_COLUMN_GAP;
|
||||
const columns = Math.floor((width + gap) / (MIN_COLUMN_WIDTH + gap));
|
||||
return clamp(columns, 1, maxColumns);
|
||||
},
|
||||
});
|
||||
@@ -16,14 +16,12 @@ interface CacheResult<T> {
|
||||
* @param args extra arguments to pass to the function to fetch the data
|
||||
* @returns
|
||||
*/
|
||||
export const timeCachePromiseFunc = async <T>(
|
||||
export const timeCachePromiseFunc = async <T, H = HomeAssistant>(
|
||||
cacheKey: string,
|
||||
cacheTime: number,
|
||||
func: (hass: HomeAssistant, ...args: any[]) => Promise<T>,
|
||||
generateCacheKey:
|
||||
| ((hass: HomeAssistant, lastResult: T) => unknown)
|
||||
| undefined,
|
||||
hass: HomeAssistant,
|
||||
func: (hass: H, ...args: any[]) => Promise<T>,
|
||||
generateCacheKey: ((hass: H, lastResult: T) => unknown) | undefined,
|
||||
hass: H,
|
||||
...args: any[]
|
||||
): Promise<T> => {
|
||||
const anyHass = hass as any;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import type { Condition } from "../../data/automation";
|
||||
import { subscribeCondition } from "../../data/automation";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-tooltip";
|
||||
import "./ha-automation-row-live-test";
|
||||
import type { LiveTestState } from "./ha-automation-row-live-test";
|
||||
|
||||
@customElement("ha-automation-condition-live-test")
|
||||
export class HaAutomationConditionLiveTest extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public condition!: Condition;
|
||||
|
||||
@state() private _liveTestResult: {
|
||||
state: LiveTestState;
|
||||
message?: string;
|
||||
} = { state: "unknown" };
|
||||
|
||||
private _conditionUnsub?: Promise<UnsubscribeFunc>;
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._subscribeCondition();
|
||||
}
|
||||
|
||||
protected override updated(changedProps: PropertyValues<this>): void {
|
||||
super.updated(changedProps);
|
||||
if (
|
||||
changedProps.has("condition") &&
|
||||
changedProps.get("condition") !== undefined
|
||||
) {
|
||||
this._resetSubscription();
|
||||
this._debounceSubscribeCondition();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._debounceSubscribeCondition.cancel();
|
||||
this._resetSubscription();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div id="indicator">
|
||||
<slot></slot>
|
||||
<ha-automation-row-live-test
|
||||
.state=${this._liveTestResult.state}
|
||||
.label=${this.hass.localize(
|
||||
`ui.panel.config.automation.editor.conditions.live_test_state.${this._liveTestResult.state}`
|
||||
)}
|
||||
></ha-automation-row-live-test>
|
||||
</div>
|
||||
${this._liveTestResult.message
|
||||
? html`<ha-tooltip for="indicator"
|
||||
>${this._liveTestResult.message}</ha-tooltip
|
||||
>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _resetSubscription() {
|
||||
this._liveTestResult = {
|
||||
state: "unknown",
|
||||
message: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.live_test_state.unknown"
|
||||
),
|
||||
};
|
||||
if (this._conditionUnsub) {
|
||||
this._conditionUnsub.then((unsub) => unsub());
|
||||
this._conditionUnsub = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _debounceSubscribeCondition = debounce(
|
||||
() => this._subscribeCondition(),
|
||||
500
|
||||
);
|
||||
|
||||
private async _subscribeCondition() {
|
||||
this._resetSubscription();
|
||||
|
||||
if (!this.condition) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conditionUnsub = subscribeCondition(
|
||||
this.hass.connection,
|
||||
(result) => {
|
||||
if (result.error) {
|
||||
this._handleLiveTestError(result.error);
|
||||
} else {
|
||||
this._liveTestResult = {
|
||||
state: result.result ? "pass" : "fail",
|
||||
message: this.hass.localize(
|
||||
`ui.panel.config.automation.editor.conditions.testing_${result.result ? "pass" : "error"}`
|
||||
),
|
||||
};
|
||||
}
|
||||
},
|
||||
this.condition
|
||||
);
|
||||
conditionUnsub.catch((err: any) => {
|
||||
this._handleLiveTestError(err);
|
||||
if (this._conditionUnsub === conditionUnsub) {
|
||||
this._conditionUnsub = undefined;
|
||||
}
|
||||
});
|
||||
this._conditionUnsub = conditionUnsub;
|
||||
}
|
||||
|
||||
private _handleLiveTestError(error: any) {
|
||||
const invalid =
|
||||
typeof error !== "string" && error.code === "invalid_format";
|
||||
this._liveTestResult = {
|
||||
state: invalid ? "invalid" : "unknown",
|
||||
message: this.hass.localize(
|
||||
`ui.panel.config.automation.editor.conditions.${invalid ? "invalid_condition" : "live_test_state.unknown"}`
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
}
|
||||
#indicator {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-automation-condition-live-test": HaAutomationConditionLiveTest;
|
||||
}
|
||||
}
|
||||
@@ -161,6 +161,8 @@ export class HaAutomationRow extends LitElement {
|
||||
}
|
||||
.leading-icon-wrapper {
|
||||
padding-top: var(--ha-space-3);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
::slotted([slot="leading-icon"]) {
|
||||
color: var(--ha-color-on-neutral-quiet);
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
import type { LineSeriesOption } from "echarts";
|
||||
|
||||
type Point = NonNullable<LineSeriesOption["data"]>[number];
|
||||
|
||||
interface MeanFrame {
|
||||
sumX: number;
|
||||
sumY: number;
|
||||
count: number;
|
||||
isArray: boolean;
|
||||
}
|
||||
|
||||
interface MinMaxFrame {
|
||||
minPoint: Point;
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxPoint: Point;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
export function downSampleLineData<
|
||||
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
|
||||
>(
|
||||
@@ -19,11 +37,47 @@ export function downSampleLineData<
|
||||
const max = maxX ?? getPointData(data[data.length - 1]!)[0];
|
||||
const step = Math.ceil((max - min) / Math.floor(maxDetails));
|
||||
|
||||
// Group points into frames
|
||||
const frames = new Map<
|
||||
number,
|
||||
{ point: (typeof data)[number]; x: number; y: number }[]
|
||||
>();
|
||||
if (useMean) {
|
||||
// Group points into frames, accumulating sums in insertion order.
|
||||
const frames = new Map<number, MeanFrame>();
|
||||
|
||||
for (const point of data) {
|
||||
const pointData = getPointData(point);
|
||||
if (!Array.isArray(pointData)) continue;
|
||||
const x = Number(pointData[0]);
|
||||
const y = Number(pointData[1]);
|
||||
if (isNaN(x) || isNaN(y)) continue;
|
||||
|
||||
const frameIndex = Math.floor((x - min) / step);
|
||||
const frame = frames.get(frameIndex);
|
||||
if (!frame) {
|
||||
frames.set(frameIndex, {
|
||||
sumX: x,
|
||||
sumY: y,
|
||||
count: 1,
|
||||
isArray: Array.isArray(pointData),
|
||||
});
|
||||
} else {
|
||||
frame.sumX += x;
|
||||
frame.sumY += y;
|
||||
frame.count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const result: T[] = [];
|
||||
for (const frame of frames.values()) {
|
||||
const meanX = frame.sumX / frame.count;
|
||||
const meanY = frame.sumY / frame.count;
|
||||
const meanPoint = (
|
||||
frame.isArray ? [meanX, meanY] : { value: [meanX, meanY] }
|
||||
) as T;
|
||||
result.push(meanPoint);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Min/max mode: track the min and max point per frame in insertion order.
|
||||
const frames = new Map<number, MinMaxFrame>();
|
||||
|
||||
for (const point of data) {
|
||||
const pointData = getPointData(point);
|
||||
@@ -35,53 +89,39 @@ export function downSampleLineData<
|
||||
const frameIndex = Math.floor((x - min) / step);
|
||||
const frame = frames.get(frameIndex);
|
||||
if (!frame) {
|
||||
frames.set(frameIndex, [{ point, x, y }]);
|
||||
frames.set(frameIndex, {
|
||||
minPoint: point,
|
||||
minX: x,
|
||||
minY: y,
|
||||
maxPoint: point,
|
||||
maxX: x,
|
||||
maxY: y,
|
||||
});
|
||||
} else {
|
||||
frame.push({ point, x, y });
|
||||
// Match the original strict-less / strict-greater comparisons so the
|
||||
// first occurrence wins on ties.
|
||||
if (y < frame.minY) {
|
||||
frame.minPoint = point;
|
||||
frame.minX = x;
|
||||
frame.minY = y;
|
||||
}
|
||||
if (y > frame.maxY) {
|
||||
frame.maxPoint = point;
|
||||
frame.maxX = x;
|
||||
frame.maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert frames back to points
|
||||
const result: T[] = [];
|
||||
|
||||
if (useMean) {
|
||||
// Use mean values for each frame
|
||||
for (const [_i, framePoints] of frames) {
|
||||
const sumY = framePoints.reduce((acc, p) => acc + p.y, 0);
|
||||
const meanY = sumY / framePoints.length;
|
||||
const sumX = framePoints.reduce((acc, p) => acc + p.x, 0);
|
||||
const meanX = sumX / framePoints.length;
|
||||
|
||||
const firstPoint = framePoints[0].point;
|
||||
const pointData = getPointData(firstPoint);
|
||||
const meanPoint = (
|
||||
Array.isArray(pointData) ? [meanX, meanY] : { value: [meanX, meanY] }
|
||||
) as T;
|
||||
result.push(meanPoint);
|
||||
for (const frame of frames.values()) {
|
||||
// The order of the data must be preserved so max may be before min
|
||||
if (frame.minX > frame.maxX) {
|
||||
result.push(frame.maxPoint as T);
|
||||
}
|
||||
} else {
|
||||
// Use min/max values for each frame
|
||||
for (const [_i, framePoints] of frames) {
|
||||
let minPoint = framePoints[0];
|
||||
let maxPoint = framePoints[0];
|
||||
|
||||
for (const p of framePoints) {
|
||||
if (p.y < minPoint.y) {
|
||||
minPoint = p;
|
||||
}
|
||||
if (p.y > maxPoint.y) {
|
||||
maxPoint = p;
|
||||
}
|
||||
}
|
||||
|
||||
// The order of the data must be preserved so max may be before min
|
||||
if (minPoint.x > maxPoint.x) {
|
||||
result.push(maxPoint.point);
|
||||
}
|
||||
result.push(minPoint.point);
|
||||
if (minPoint.x < maxPoint.x) {
|
||||
result.push(maxPoint.point);
|
||||
}
|
||||
result.push(frame.minPoint as T);
|
||||
if (frame.minX < frame.maxX) {
|
||||
result.push(frame.maxPoint as T);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -394,6 +394,18 @@ export class HaChartBase extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
const datasets = ensureArray(this.data!);
|
||||
// Index datasets by id and name so each legend item is an O(1) lookup
|
||||
// instead of scanning every dataset twice. Charts can have many series.
|
||||
const datasetById = new Map<unknown, (typeof datasets)[number]>();
|
||||
const datasetByName = new Map<unknown, (typeof datasets)[number]>();
|
||||
for (const dataset of datasets) {
|
||||
if (dataset.id !== undefined && !datasetById.has(dataset.id)) {
|
||||
datasetById.set(dataset.id, dataset);
|
||||
}
|
||||
if (dataset.name !== undefined && !datasetByName.has(dataset.name)) {
|
||||
datasetByName.set(dataset.name, dataset);
|
||||
}
|
||||
}
|
||||
|
||||
const isMobile = window.matchMedia(
|
||||
"all and (max-width: 450px), all and (max-height: 500px)"
|
||||
@@ -413,10 +425,10 @@ export class HaChartBase extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
let itemStyle: Record<string, any> = {};
|
||||
let id = "";
|
||||
let value = "";
|
||||
let noLabelClick = false;
|
||||
const name = typeof item === "string" ? item : (item.name ?? "");
|
||||
let id: string;
|
||||
if (typeof item === "string") {
|
||||
id = item;
|
||||
} else {
|
||||
@@ -426,9 +438,7 @@ export class HaChartBase extends LitElement {
|
||||
noLabelClick = item.noLabelClick ?? false;
|
||||
}
|
||||
const labelClickable = this.clickLabelForMoreInfo && !noLabelClick;
|
||||
const dataset =
|
||||
datasets.find((d) => d.id === id) ??
|
||||
datasets.find((d) => d.name === id);
|
||||
const dataset = datasetById.get(id) ?? datasetByName.get(id);
|
||||
itemStyle = {
|
||||
color: dataset?.color as string,
|
||||
...(dataset?.itemStyle as { borderColor?: string }),
|
||||
@@ -1520,7 +1530,9 @@ export class HaChartBase extends LitElement {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
line-height: 1;
|
||||
/* overflow: hidden clips descenders (e.g. "g", parentheses) with a tight
|
||||
line-height, so give the line box room to contain them */
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.chart-legend .label.clickable:hover {
|
||||
@@ -1558,6 +1570,25 @@ export class HaChartBase extends LitElement {
|
||||
.chart-legend .legend-toggle ha-svg-icon {
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
/* On touch devices, enlarge the toggle tap target via taller rows and
|
||||
leading padding (which also separates it from the previous item), while
|
||||
keeping the icon tight to its own label so the pairing stays clear.
|
||||
Drop the now-pointless row gap and li padding. */
|
||||
@media (pointer: coarse) {
|
||||
.chart-legend ul {
|
||||
row-gap: 0;
|
||||
}
|
||||
/* Only grow the toggle rows, not the expand/collapse chip's row. */
|
||||
.chart-legend li:has(.legend-toggle) {
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
}
|
||||
.chart-legend .legend-toggle {
|
||||
padding: 11px;
|
||||
padding-inline-end: 4px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
ha-assist-chip {
|
||||
height: 100%;
|
||||
--_label-text-weight: 500;
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
import type { LineSeriesOption } from "echarts/charts";
|
||||
import type { VisualMapComponentOption } from "echarts/components";
|
||||
import { getGraphColorByIndex } from "../../common/color/colors";
|
||||
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
|
||||
import type { LineChartEntity, LineChartState } from "../../data/history";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
|
||||
|
||||
const safeParseFloat = (value) => {
|
||||
const parsed = parseFloat(value);
|
||||
return isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
export const CLIMATE_MODE_CONFIGS = [
|
||||
{ mode: "heat", action: "heating", cssVar: "--state-climate-heat-color" },
|
||||
{ mode: "cool", action: "cooling", cssVar: "--state-climate-cool-color" },
|
||||
{ mode: "dry", action: "drying", cssVar: "--state-climate-dry-color" },
|
||||
{ mode: "fan_only", action: "fan", cssVar: "--state-climate-fan_only-color" },
|
||||
] as const;
|
||||
|
||||
export interface StateHistoryChartLineDataParams {
|
||||
hass: HomeAssistant;
|
||||
data: LineChartEntity[];
|
||||
endTime: Date;
|
||||
names?: Record<string, string>;
|
||||
colors?: Record<string, string | undefined>;
|
||||
showNames: boolean;
|
||||
computedStyles: CSSStyleDeclaration;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
export interface StateHistoryChartLineData {
|
||||
datasets: LineSeriesOption[];
|
||||
entityIds: string[];
|
||||
datasetToDataIndex: number[];
|
||||
visualMap?: VisualMapComponentOption[];
|
||||
yAxisFractionDigits: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms processed history (`LineChartEntity[]`) into ECharts series for
|
||||
* `state-history-chart-line`. Pure data processing: all environment inputs
|
||||
* (current time, theme style, hass) are injected so the transform is
|
||||
* deterministic and benchmarkable.
|
||||
*/
|
||||
export function generateStateHistoryChartLineData(
|
||||
params: StateHistoryChartLineDataParams
|
||||
): StateHistoryChartLineData | undefined {
|
||||
const { hass, computedStyles, endTime } = params;
|
||||
// Work with numeric epoch timestamps (ms) instead of Date objects below.
|
||||
// Charts can hold a huge number of points, and allocating a Date per point
|
||||
// is needless GC pressure; the "time" axis consumes numbers natively.
|
||||
const endTimeMs = endTime.getTime();
|
||||
|
||||
let colorIndex = 0;
|
||||
const entityStates = params.data;
|
||||
const datasets: LineSeriesOption[] = [];
|
||||
const entityIds: string[] = [];
|
||||
const datasetToDataIndex: number[] = [];
|
||||
let yMin = Infinity;
|
||||
let yMax = -Infinity;
|
||||
const trackY = (v: number | null | undefined) => {
|
||||
if (typeof v === "number" && Number.isFinite(v)) {
|
||||
if (v < yMin) yMin = v;
|
||||
if (v > yMax) yMax = v;
|
||||
}
|
||||
};
|
||||
if (entityStates.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const names = params.names || {};
|
||||
const colors = params.colors || {};
|
||||
entityStates.forEach((states, dataIdx) => {
|
||||
const domain = states.domain;
|
||||
const name = names[states.entity_id] || states.name;
|
||||
const color = colors[states.entity_id];
|
||||
// array containing [value1, value2, etc]
|
||||
let prevValues: any[] | null = null;
|
||||
|
||||
const data: LineSeriesOption[] = [];
|
||||
|
||||
const pushData = (timestamp: number, datavalues: any[] | null) => {
|
||||
if (!datavalues) return;
|
||||
if (timestamp > endTimeMs) {
|
||||
// Drop data points that are after the requested endTime. This could happen if
|
||||
// endTime is "now" and client time is not in sync with server time.
|
||||
return;
|
||||
}
|
||||
data.forEach((d, i) => {
|
||||
if (datavalues[i] === null && prevValues && prevValues[i] !== null) {
|
||||
// null data values show up as gaps in the chart.
|
||||
// If the current value for the dataset is null and the previous
|
||||
// value of the data set is not null, then add an 'end' point
|
||||
// to the chart for the previous value. Otherwise the gap will
|
||||
// be too big. It will go from the start of the previous data
|
||||
// value until the start of the next data value.
|
||||
d.data!.push([timestamp, prevValues[i]]);
|
||||
}
|
||||
d.data!.push([timestamp, datavalues[i]]);
|
||||
trackY(datavalues[i]);
|
||||
});
|
||||
prevValues = datavalues;
|
||||
};
|
||||
|
||||
const addDataSet = (
|
||||
id: string,
|
||||
nameY: string,
|
||||
clr?: string,
|
||||
fill = false
|
||||
) => {
|
||||
if (!clr) {
|
||||
clr = getGraphColorByIndex(colorIndex, computedStyles);
|
||||
colorIndex++;
|
||||
}
|
||||
data.push({
|
||||
id,
|
||||
data: [],
|
||||
type: "line",
|
||||
cursor: "default",
|
||||
name: nameY,
|
||||
color: clr,
|
||||
symbol: "circle",
|
||||
symbolSize: 1,
|
||||
step: "end",
|
||||
sampling: "minmax",
|
||||
animationDurationUpdate: 0,
|
||||
lineStyle: {
|
||||
width: fill ? 0 : 1.5,
|
||||
},
|
||||
areaStyle: fill
|
||||
? {
|
||||
color: clr + "7F",
|
||||
}
|
||||
: undefined,
|
||||
tooltip: {
|
||||
show: !fill,
|
||||
},
|
||||
});
|
||||
entityIds.push(states.entity_id);
|
||||
datasetToDataIndex.push(dataIdx);
|
||||
};
|
||||
|
||||
if (
|
||||
domain === "thermostat" ||
|
||||
domain === "climate" ||
|
||||
domain === "water_heater"
|
||||
) {
|
||||
const hasHvacAction = states.states.some(
|
||||
(entityState) => entityState.attributes?.hvac_action
|
||||
);
|
||||
|
||||
const activeModes = CLIMATE_MODE_CONFIGS.map(
|
||||
({ mode, action, cssVar }) => {
|
||||
const isActive =
|
||||
domain === "climate" && hasHvacAction
|
||||
? (entityState: LineChartState) =>
|
||||
CLIMATE_HVAC_ACTION_TO_MODE[
|
||||
entityState.attributes?.hvac_action
|
||||
] === mode
|
||||
: (entityState: LineChartState) => entityState.state === mode;
|
||||
return { action, cssVar, isActive };
|
||||
}
|
||||
).filter(({ isActive }) => states.states.some(isActive));
|
||||
// We differentiate between thermostats that have a target temperature
|
||||
// range versus ones that have just a target temperature
|
||||
|
||||
// Using step chart by step-before so manually interpolation not needed.
|
||||
const hasTargetRange = states.states.some(
|
||||
(entityState) =>
|
||||
entityState.attributes &&
|
||||
entityState.attributes.target_temp_high !==
|
||||
entityState.attributes.target_temp_low
|
||||
);
|
||||
addDataSet(
|
||||
states.entity_id + "-current_temperature",
|
||||
params.showNames
|
||||
? hass.localize("ui.card.climate.current_temperature", {
|
||||
name: name,
|
||||
})
|
||||
: hass.localize(
|
||||
"component.climate.entity_component._.state_attributes.current_temperature.name"
|
||||
)
|
||||
);
|
||||
for (const { action, cssVar } of activeModes) {
|
||||
addDataSet(
|
||||
`${states.entity_id}-${action}`,
|
||||
params.showNames
|
||||
? hass.localize(`ui.card.climate.${action}`, {
|
||||
name: name,
|
||||
})
|
||||
: hass.localize(
|
||||
`component.climate.entity_component._.state_attributes.hvac_action.state.${action}`
|
||||
),
|
||||
computedStyles.getPropertyValue(cssVar),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (hasTargetRange) {
|
||||
addDataSet(
|
||||
states.entity_id + "-target_temperature_mode",
|
||||
params.showNames
|
||||
? hass.localize("ui.card.climate.target_temperature_mode", {
|
||||
name: name,
|
||||
mode: hass.localize("ui.card.climate.high"),
|
||||
})
|
||||
: hass.localize(
|
||||
"component.climate.entity_component._.state_attributes.target_temp_high.name"
|
||||
)
|
||||
);
|
||||
addDataSet(
|
||||
states.entity_id + "-target_temperature_mode_low",
|
||||
params.showNames
|
||||
? hass.localize("ui.card.climate.target_temperature_mode", {
|
||||
name: name,
|
||||
mode: hass.localize("ui.card.climate.low"),
|
||||
})
|
||||
: hass.localize(
|
||||
"component.climate.entity_component._.state_attributes.target_temp_low.name"
|
||||
)
|
||||
);
|
||||
} else {
|
||||
addDataSet(
|
||||
states.entity_id + "-target_temperature",
|
||||
params.showNames
|
||||
? hass.localize("ui.card.climate.target_temperature_entity", {
|
||||
name: name,
|
||||
})
|
||||
: hass.localize(
|
||||
"component.climate.entity_component._.state_attributes.temperature.name"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
states.states.forEach((entityState) => {
|
||||
if (!entityState.attributes) return;
|
||||
const curTemp = safeParseFloat(
|
||||
entityState.attributes.current_temperature
|
||||
);
|
||||
const series = [curTemp];
|
||||
for (const { isActive } of activeModes) {
|
||||
series.push(isActive(entityState) ? curTemp : null);
|
||||
}
|
||||
if (hasTargetRange) {
|
||||
const targetHigh = safeParseFloat(
|
||||
entityState.attributes.target_temp_high
|
||||
);
|
||||
const targetLow = safeParseFloat(
|
||||
entityState.attributes.target_temp_low
|
||||
);
|
||||
series.push(targetHigh, targetLow);
|
||||
pushData(entityState.last_changed, series);
|
||||
} else {
|
||||
const target = safeParseFloat(entityState.attributes.temperature);
|
||||
series.push(target);
|
||||
pushData(entityState.last_changed, series);
|
||||
}
|
||||
});
|
||||
} else if (domain === "humidifier") {
|
||||
const hasAction = states.states.some(
|
||||
(entityState) => entityState.attributes?.action
|
||||
);
|
||||
const hasCurrent = states.states.some(
|
||||
(entityState) => entityState.attributes?.current_humidity
|
||||
);
|
||||
|
||||
const hasHumidifying =
|
||||
hasAction &&
|
||||
states.states.some(
|
||||
(entityState: LineChartState) =>
|
||||
entityState.attributes?.action === "humidifying"
|
||||
);
|
||||
const hasDrying =
|
||||
hasAction &&
|
||||
states.states.some(
|
||||
(entityState: LineChartState) =>
|
||||
entityState.attributes?.action === "drying"
|
||||
);
|
||||
|
||||
addDataSet(
|
||||
states.entity_id + "-target_humidity",
|
||||
params.showNames
|
||||
? hass.localize("ui.card.humidifier.target_humidity_entity", {
|
||||
name: name,
|
||||
})
|
||||
: hass.localize(
|
||||
"component.humidifier.entity_component._.state_attributes.humidity.name"
|
||||
)
|
||||
);
|
||||
|
||||
if (hasCurrent) {
|
||||
addDataSet(
|
||||
states.entity_id + "-current_humidity",
|
||||
params.showNames
|
||||
? hass.localize("ui.card.humidifier.current_humidity_entity", {
|
||||
name: name,
|
||||
})
|
||||
: hass.localize(
|
||||
"component.humidifier.entity_component._.state_attributes.current_humidity.name"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// If action attribute is available, we used it to shade the area below the humidity.
|
||||
// If action attribute is not available, we shade the area when the device is on
|
||||
if (hasHumidifying) {
|
||||
addDataSet(
|
||||
states.entity_id + "-humidifying",
|
||||
params.showNames
|
||||
? hass.localize("ui.card.humidifier.humidifying", {
|
||||
name: name,
|
||||
})
|
||||
: hass.localize(
|
||||
"component.humidifier.entity_component._.state_attributes.action.state.humidifying"
|
||||
),
|
||||
computedStyles.getPropertyValue("--state-humidifier-on-color"),
|
||||
true
|
||||
);
|
||||
} else if (hasDrying) {
|
||||
addDataSet(
|
||||
states.entity_id + "-drying",
|
||||
params.showNames
|
||||
? hass.localize("ui.card.humidifier.drying", {
|
||||
name: name,
|
||||
})
|
||||
: hass.localize(
|
||||
"component.humidifier.entity_component._.state_attributes.action.state.drying"
|
||||
),
|
||||
computedStyles.getPropertyValue("--state-humidifier-on-color"),
|
||||
true
|
||||
);
|
||||
} else {
|
||||
addDataSet(
|
||||
states.entity_id + "-on",
|
||||
params.showNames
|
||||
? hass.localize("ui.card.humidifier.on_entity", {
|
||||
name: name,
|
||||
})
|
||||
: hass.localize("component.humidifier.entity_component._.state.on"),
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
states.states.forEach((entityState) => {
|
||||
if (!entityState.attributes) return;
|
||||
const target = safeParseFloat(entityState.attributes.humidity);
|
||||
// If the current humidity is not available, then we fill up to the target humidity
|
||||
const current = hasCurrent
|
||||
? safeParseFloat(entityState.attributes?.current_humidity)
|
||||
: target;
|
||||
const series = [target];
|
||||
|
||||
if (hasCurrent) {
|
||||
series.push(current);
|
||||
}
|
||||
|
||||
if (hasHumidifying) {
|
||||
series.push(
|
||||
entityState.attributes?.action === "humidifying" ? current : null
|
||||
);
|
||||
} else if (hasDrying) {
|
||||
series.push(
|
||||
entityState.attributes?.action === "drying" ? current : null
|
||||
);
|
||||
} else {
|
||||
series.push(entityState.state === "on" ? current : null);
|
||||
}
|
||||
pushData(entityState.last_changed, series);
|
||||
});
|
||||
} else {
|
||||
addDataSet(states.entity_id, name, color);
|
||||
|
||||
let lastValue: number;
|
||||
let lastDate: number;
|
||||
let lastNullDate: number | null = null;
|
||||
|
||||
// Process chart data.
|
||||
// When state is `unknown`, calculate the value and break the line.
|
||||
const processData = (entityState: LineChartState) => {
|
||||
const value = safeParseFloat(entityState.state);
|
||||
const date = entityState.last_changed;
|
||||
if (value !== null && lastNullDate) {
|
||||
const tmpValue =
|
||||
(value - lastValue) *
|
||||
((lastNullDate - lastDate) / (date - lastDate)) +
|
||||
lastValue;
|
||||
pushData(lastNullDate, [tmpValue]);
|
||||
pushData(lastNullDate + 1, [null]);
|
||||
pushData(date, [value]);
|
||||
lastDate = date;
|
||||
lastValue = value;
|
||||
lastNullDate = null;
|
||||
} else if (value !== null && lastNullDate === null) {
|
||||
pushData(date, [value]);
|
||||
lastDate = date;
|
||||
lastValue = value;
|
||||
} else if (
|
||||
value === null &&
|
||||
lastNullDate === null &&
|
||||
lastValue !== undefined
|
||||
) {
|
||||
lastNullDate = date;
|
||||
}
|
||||
};
|
||||
|
||||
if (states.statistics) {
|
||||
const stopTime =
|
||||
!states.states || states.states.length === 0
|
||||
? 0
|
||||
: states.states[0].last_changed;
|
||||
for (const statistic of states.statistics) {
|
||||
if (stopTime && statistic.last_changed >= stopTime) {
|
||||
break;
|
||||
}
|
||||
processData(statistic);
|
||||
}
|
||||
}
|
||||
states.states.forEach((entityState) => {
|
||||
processData(entityState);
|
||||
});
|
||||
if (lastNullDate !== null) {
|
||||
pushData(lastNullDate, [null]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add an entry for final values
|
||||
pushData(endTimeMs, prevValues);
|
||||
|
||||
// For sensors, append current state if viewing recent data
|
||||
const nowMs = params.now.getTime();
|
||||
// allow 1s of leeway for "now"
|
||||
const isUpToNow = nowMs - endTimeMs <= 1000;
|
||||
if (domain === "sensor" && isUpToNow && data.length === 1) {
|
||||
const stateObj = hass.states[states.entity_id];
|
||||
const currentValue = stateObj ? safeParseFloat(stateObj.state) : null;
|
||||
if (currentValue !== null) {
|
||||
data[0].data!.push([nowMs, currentValue]);
|
||||
trackY(currentValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Concat two arrays
|
||||
Array.prototype.push.apply(datasets, data);
|
||||
});
|
||||
|
||||
const visualMap: VisualMapComponentOption[] = [];
|
||||
datasets.forEach((_, seriesIndex) => {
|
||||
const dataIndex = datasetToDataIndex[seriesIndex];
|
||||
const data = entityStates[dataIndex];
|
||||
if (!data.statistics || data.statistics.length === 0) {
|
||||
return;
|
||||
}
|
||||
// render stat data with a slightly transparent line
|
||||
const firstStateTS = data.states[0]?.last_changed ?? endTime.getTime();
|
||||
visualMap.push({
|
||||
show: false,
|
||||
seriesIndex,
|
||||
dimension: 0,
|
||||
pieces: [
|
||||
{
|
||||
max: firstStateTS - 0.01,
|
||||
colorAlpha: 0.5,
|
||||
},
|
||||
{
|
||||
min: firstStateTS,
|
||||
colorAlpha: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
datasets,
|
||||
entityIds,
|
||||
datasetToDataIndex,
|
||||
visualMap: visualMap.length > 0 ? visualMap : undefined,
|
||||
yAxisFractionDigits: computeYAxisFractionDigits(yMin, yMax),
|
||||
};
|
||||
}
|
||||
@@ -5,15 +5,17 @@ import type { VisualMapComponentOption } from "echarts/components";
|
||||
import type { LineSeriesOption } from "echarts/charts";
|
||||
import type { YAXisOption } from "echarts/types/dist/shared";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { getGraphColorByIndex } from "../../common/color/colors";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
|
||||
import type { LineChartEntity, LineChartState } from "../../data/history";
|
||||
import type { LineChartEntity } from "../../data/history";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
|
||||
import {
|
||||
CLIMATE_MODE_CONFIGS,
|
||||
generateStateHistoryChartLineData,
|
||||
} from "./state-history-chart-line-data";
|
||||
import type { HaECOption } from "../../resources/echarts/echarts";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import {
|
||||
@@ -23,22 +25,9 @@ import {
|
||||
import { measureTextWidth } from "../../util/text";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
|
||||
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
|
||||
import { computeAttributeValueDisplay } from "../../common/entity/compute_attribute_display";
|
||||
|
||||
const safeParseFloat = (value) => {
|
||||
const parsed = parseFloat(value);
|
||||
return isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const CLIMATE_MODE_CONFIGS = [
|
||||
{ mode: "heat", action: "heating", cssVar: "--state-climate-heat-color" },
|
||||
{ mode: "cool", action: "cooling", cssVar: "--state-climate-cool-color" },
|
||||
{ mode: "dry", action: "drying", cssVar: "--state-climate-dry-color" },
|
||||
{ mode: "fan_only", action: "fan", cssVar: "--state-climate-fan_only-color" },
|
||||
] as const;
|
||||
|
||||
// Used to recover the underlying entity_id from a legend dataset id.
|
||||
// Kept in sync with the suffixes appended at dataset construction below
|
||||
// for climate / water_heater / humidifier multi-attribute charts.
|
||||
@@ -147,6 +136,14 @@ export class StateHistoryChartLine extends LitElement {
|
||||
this.hass.config
|
||||
);
|
||||
const datapoints: Record<string, any>[] = [];
|
||||
// Index the hovered points by series so the per-dataset lookup below is
|
||||
// O(1) instead of scanning `params` for every dataset on each mouse move.
|
||||
const paramsBySeriesIndex = new Map<number, Record<string, any>>();
|
||||
for (const p of params) {
|
||||
if (!paramsBySeriesIndex.has(p.seriesIndex)) {
|
||||
paramsBySeriesIndex.set(p.seriesIndex, p);
|
||||
}
|
||||
}
|
||||
this._chartData.forEach((dataset, index) => {
|
||||
if (
|
||||
dataset.tooltip?.show === false ||
|
||||
@@ -154,9 +151,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const param = params.find(
|
||||
(p: Record<string, any>) => p.seriesIndex === index
|
||||
);
|
||||
const param = paramsBySeriesIndex.get(index);
|
||||
if (param) {
|
||||
datapoints.push(param);
|
||||
return;
|
||||
@@ -420,445 +415,32 @@ export class StateHistoryChartLine extends LitElement {
|
||||
}
|
||||
|
||||
private _generateData() {
|
||||
let colorIndex = 0;
|
||||
const computedStyles = getComputedStyle(this);
|
||||
const entityStates = this.data;
|
||||
const datasets: LineSeriesOption[] = [];
|
||||
const entityIds: string[] = [];
|
||||
const datasetToDataIndex: number[] = [];
|
||||
let yMin = Infinity;
|
||||
let yMax = -Infinity;
|
||||
const trackY = (v: number | null | undefined) => {
|
||||
if (typeof v === "number" && Number.isFinite(v)) {
|
||||
if (v < yMin) yMin = v;
|
||||
if (v > yMax) yMax = v;
|
||||
}
|
||||
};
|
||||
if (entityStates.length === 0) {
|
||||
if (this.data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._chartTime = new Date();
|
||||
const endTime = this.endTime;
|
||||
const names = this.names || {};
|
||||
const colors = this.colors || {};
|
||||
entityStates.forEach((states, dataIdx) => {
|
||||
const domain = states.domain;
|
||||
const name = names[states.entity_id] || states.name;
|
||||
const color = colors[states.entity_id];
|
||||
// array containing [value1, value2, etc]
|
||||
let prevValues: any[] | null = null;
|
||||
|
||||
const data: LineSeriesOption[] = [];
|
||||
|
||||
const pushData = (timestamp: Date, datavalues: any[] | null) => {
|
||||
if (!datavalues) return;
|
||||
if (timestamp > endTime) {
|
||||
// Drop data points that are after the requested endTime. This could happen if
|
||||
// endTime is "now" and client time is not in sync with server time.
|
||||
return;
|
||||
}
|
||||
data.forEach((d, i) => {
|
||||
if (datavalues[i] === null && prevValues && prevValues[i] !== null) {
|
||||
// null data values show up as gaps in the chart.
|
||||
// If the current value for the dataset is null and the previous
|
||||
// value of the data set is not null, then add an 'end' point
|
||||
// to the chart for the previous value. Otherwise the gap will
|
||||
// be too big. It will go from the start of the previous data
|
||||
// value until the start of the next data value.
|
||||
d.data!.push([timestamp, prevValues[i]]);
|
||||
}
|
||||
d.data!.push([timestamp, datavalues[i]]);
|
||||
trackY(datavalues[i]);
|
||||
});
|
||||
prevValues = datavalues;
|
||||
};
|
||||
|
||||
const addDataSet = (
|
||||
id: string,
|
||||
nameY: string,
|
||||
clr?: string,
|
||||
fill = false
|
||||
) => {
|
||||
if (!clr) {
|
||||
clr = getGraphColorByIndex(colorIndex, computedStyles);
|
||||
colorIndex++;
|
||||
}
|
||||
data.push({
|
||||
id,
|
||||
data: [],
|
||||
type: "line",
|
||||
cursor: "default",
|
||||
name: nameY,
|
||||
color: clr,
|
||||
symbol: "circle",
|
||||
symbolSize: 1,
|
||||
step: "end",
|
||||
sampling: "minmax",
|
||||
animationDurationUpdate: 0,
|
||||
lineStyle: {
|
||||
width: fill ? 0 : 1.5,
|
||||
},
|
||||
areaStyle: fill
|
||||
? {
|
||||
color: clr + "7F",
|
||||
}
|
||||
: undefined,
|
||||
tooltip: {
|
||||
show: !fill,
|
||||
},
|
||||
});
|
||||
entityIds.push(states.entity_id);
|
||||
datasetToDataIndex.push(dataIdx);
|
||||
};
|
||||
|
||||
if (
|
||||
domain === "thermostat" ||
|
||||
domain === "climate" ||
|
||||
domain === "water_heater"
|
||||
) {
|
||||
const hasHvacAction = states.states.some(
|
||||
(entityState) => entityState.attributes?.hvac_action
|
||||
);
|
||||
|
||||
const activeModes = CLIMATE_MODE_CONFIGS.map(
|
||||
({ mode, action, cssVar }) => {
|
||||
const isActive =
|
||||
domain === "climate" && hasHvacAction
|
||||
? (entityState: LineChartState) =>
|
||||
CLIMATE_HVAC_ACTION_TO_MODE[
|
||||
entityState.attributes?.hvac_action
|
||||
] === mode
|
||||
: (entityState: LineChartState) => entityState.state === mode;
|
||||
return { action, cssVar, isActive };
|
||||
}
|
||||
).filter(({ isActive }) => states.states.some(isActive));
|
||||
// We differentiate between thermostats that have a target temperature
|
||||
// range versus ones that have just a target temperature
|
||||
|
||||
// Using step chart by step-before so manually interpolation not needed.
|
||||
const hasTargetRange = states.states.some(
|
||||
(entityState) =>
|
||||
entityState.attributes &&
|
||||
entityState.attributes.target_temp_high !==
|
||||
entityState.attributes.target_temp_low
|
||||
);
|
||||
addDataSet(
|
||||
states.entity_id + "-current_temperature",
|
||||
this.showNames
|
||||
? this.hass.localize("ui.card.climate.current_temperature", {
|
||||
name: name,
|
||||
})
|
||||
: this.hass.localize(
|
||||
"component.climate.entity_component._.state_attributes.current_temperature.name"
|
||||
)
|
||||
);
|
||||
for (const { action, cssVar } of activeModes) {
|
||||
addDataSet(
|
||||
`${states.entity_id}-${action}`,
|
||||
this.showNames
|
||||
? this.hass.localize(`ui.card.climate.${action}`, {
|
||||
name: name,
|
||||
})
|
||||
: this.hass.localize(
|
||||
`component.climate.entity_component._.state_attributes.hvac_action.state.${action}`
|
||||
),
|
||||
computedStyles.getPropertyValue(cssVar),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (hasTargetRange) {
|
||||
addDataSet(
|
||||
states.entity_id + "-target_temperature_mode",
|
||||
this.showNames
|
||||
? this.hass.localize("ui.card.climate.target_temperature_mode", {
|
||||
name: name,
|
||||
mode: this.hass.localize("ui.card.climate.high"),
|
||||
})
|
||||
: this.hass.localize(
|
||||
"component.climate.entity_component._.state_attributes.target_temp_high.name"
|
||||
)
|
||||
);
|
||||
addDataSet(
|
||||
states.entity_id + "-target_temperature_mode_low",
|
||||
this.showNames
|
||||
? this.hass.localize("ui.card.climate.target_temperature_mode", {
|
||||
name: name,
|
||||
mode: this.hass.localize("ui.card.climate.low"),
|
||||
})
|
||||
: this.hass.localize(
|
||||
"component.climate.entity_component._.state_attributes.target_temp_low.name"
|
||||
)
|
||||
);
|
||||
} else {
|
||||
addDataSet(
|
||||
states.entity_id + "-target_temperature",
|
||||
this.showNames
|
||||
? this.hass.localize(
|
||||
"ui.card.climate.target_temperature_entity",
|
||||
{
|
||||
name: name,
|
||||
}
|
||||
)
|
||||
: this.hass.localize(
|
||||
"component.climate.entity_component._.state_attributes.temperature.name"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
states.states.forEach((entityState) => {
|
||||
if (!entityState.attributes) return;
|
||||
const curTemp = safeParseFloat(
|
||||
entityState.attributes.current_temperature
|
||||
);
|
||||
const series = [curTemp];
|
||||
for (const { isActive } of activeModes) {
|
||||
series.push(isActive(entityState) ? curTemp : null);
|
||||
}
|
||||
if (hasTargetRange) {
|
||||
const targetHigh = safeParseFloat(
|
||||
entityState.attributes.target_temp_high
|
||||
);
|
||||
const targetLow = safeParseFloat(
|
||||
entityState.attributes.target_temp_low
|
||||
);
|
||||
series.push(targetHigh, targetLow);
|
||||
pushData(new Date(entityState.last_changed), series);
|
||||
} else {
|
||||
const target = safeParseFloat(entityState.attributes.temperature);
|
||||
series.push(target);
|
||||
pushData(new Date(entityState.last_changed), series);
|
||||
}
|
||||
});
|
||||
} else if (domain === "humidifier") {
|
||||
const hasAction = states.states.some(
|
||||
(entityState) => entityState.attributes?.action
|
||||
);
|
||||
const hasCurrent = states.states.some(
|
||||
(entityState) => entityState.attributes?.current_humidity
|
||||
);
|
||||
|
||||
const hasHumidifying =
|
||||
hasAction &&
|
||||
states.states.some(
|
||||
(entityState: LineChartState) =>
|
||||
entityState.attributes?.action === "humidifying"
|
||||
);
|
||||
const hasDrying =
|
||||
hasAction &&
|
||||
states.states.some(
|
||||
(entityState: LineChartState) =>
|
||||
entityState.attributes?.action === "drying"
|
||||
);
|
||||
|
||||
addDataSet(
|
||||
states.entity_id + "-target_humidity",
|
||||
this.showNames
|
||||
? this.hass.localize("ui.card.humidifier.target_humidity_entity", {
|
||||
name: name,
|
||||
})
|
||||
: this.hass.localize(
|
||||
"component.humidifier.entity_component._.state_attributes.humidity.name"
|
||||
)
|
||||
);
|
||||
|
||||
if (hasCurrent) {
|
||||
addDataSet(
|
||||
states.entity_id + "-current_humidity",
|
||||
this.showNames
|
||||
? this.hass.localize(
|
||||
"ui.card.humidifier.current_humidity_entity",
|
||||
{
|
||||
name: name,
|
||||
}
|
||||
)
|
||||
: this.hass.localize(
|
||||
"component.humidifier.entity_component._.state_attributes.current_humidity.name"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// If action attribute is available, we used it to shade the area below the humidity.
|
||||
// If action attribute is not available, we shade the area when the device is on
|
||||
if (hasHumidifying) {
|
||||
addDataSet(
|
||||
states.entity_id + "-humidifying",
|
||||
this.showNames
|
||||
? this.hass.localize("ui.card.humidifier.humidifying", {
|
||||
name: name,
|
||||
})
|
||||
: this.hass.localize(
|
||||
"component.humidifier.entity_component._.state_attributes.action.state.humidifying"
|
||||
),
|
||||
computedStyles.getPropertyValue("--state-humidifier-on-color"),
|
||||
true
|
||||
);
|
||||
} else if (hasDrying) {
|
||||
addDataSet(
|
||||
states.entity_id + "-drying",
|
||||
this.showNames
|
||||
? this.hass.localize("ui.card.humidifier.drying", {
|
||||
name: name,
|
||||
})
|
||||
: this.hass.localize(
|
||||
"component.humidifier.entity_component._.state_attributes.action.state.drying"
|
||||
),
|
||||
computedStyles.getPropertyValue("--state-humidifier-on-color"),
|
||||
true
|
||||
);
|
||||
} else {
|
||||
addDataSet(
|
||||
states.entity_id + "-on",
|
||||
this.showNames
|
||||
? this.hass.localize("ui.card.humidifier.on_entity", {
|
||||
name: name,
|
||||
})
|
||||
: this.hass.localize(
|
||||
"component.humidifier.entity_component._.state.on"
|
||||
),
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
states.states.forEach((entityState) => {
|
||||
if (!entityState.attributes) return;
|
||||
const target = safeParseFloat(entityState.attributes.humidity);
|
||||
// If the current humidity is not available, then we fill up to the target humidity
|
||||
const current = hasCurrent
|
||||
? safeParseFloat(entityState.attributes?.current_humidity)
|
||||
: target;
|
||||
const series = [target];
|
||||
|
||||
if (hasCurrent) {
|
||||
series.push(current);
|
||||
}
|
||||
|
||||
if (hasHumidifying) {
|
||||
series.push(
|
||||
entityState.attributes?.action === "humidifying" ? current : null
|
||||
);
|
||||
} else if (hasDrying) {
|
||||
series.push(
|
||||
entityState.attributes?.action === "drying" ? current : null
|
||||
);
|
||||
} else {
|
||||
series.push(entityState.state === "on" ? current : null);
|
||||
}
|
||||
pushData(new Date(entityState.last_changed), series);
|
||||
});
|
||||
} else {
|
||||
addDataSet(states.entity_id, name, color);
|
||||
|
||||
let lastValue: number;
|
||||
let lastDate: Date;
|
||||
let lastNullDate: Date | null = null;
|
||||
|
||||
// Process chart data.
|
||||
// When state is `unknown`, calculate the value and break the line.
|
||||
const processData = (entityState: LineChartState) => {
|
||||
const value = safeParseFloat(entityState.state);
|
||||
const date = new Date(entityState.last_changed);
|
||||
if (value !== null && lastNullDate) {
|
||||
const dateTime = date.getTime();
|
||||
const lastNullDateTime = lastNullDate.getTime();
|
||||
const lastDateTime = lastDate?.getTime();
|
||||
const tmpValue =
|
||||
(value - lastValue) *
|
||||
((lastNullDateTime - lastDateTime) /
|
||||
(dateTime - lastDateTime)) +
|
||||
lastValue;
|
||||
pushData(lastNullDate, [tmpValue]);
|
||||
pushData(new Date(lastNullDateTime + 1), [null]);
|
||||
pushData(date, [value]);
|
||||
lastDate = date;
|
||||
lastValue = value;
|
||||
lastNullDate = null;
|
||||
} else if (value !== null && lastNullDate === null) {
|
||||
pushData(date, [value]);
|
||||
lastDate = date;
|
||||
lastValue = value;
|
||||
} else if (
|
||||
value === null &&
|
||||
lastNullDate === null &&
|
||||
lastValue !== undefined
|
||||
) {
|
||||
lastNullDate = date;
|
||||
}
|
||||
};
|
||||
|
||||
if (states.statistics) {
|
||||
const stopTime =
|
||||
!states.states || states.states.length === 0
|
||||
? 0
|
||||
: states.states[0].last_changed;
|
||||
for (const statistic of states.statistics) {
|
||||
if (stopTime && statistic.last_changed >= stopTime) {
|
||||
break;
|
||||
}
|
||||
processData(statistic);
|
||||
}
|
||||
}
|
||||
states.states.forEach((entityState) => {
|
||||
processData(entityState);
|
||||
});
|
||||
if (lastNullDate !== null) {
|
||||
pushData(lastNullDate, [null]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add an entry for final values
|
||||
pushData(endTime, prevValues);
|
||||
|
||||
// For sensors, append current state if viewing recent data
|
||||
const now = new Date();
|
||||
// allow 1s of leeway for "now"
|
||||
const isUpToNow = now.getTime() - endTime.getTime() <= 1000;
|
||||
if (domain === "sensor" && isUpToNow && data.length === 1) {
|
||||
const stateObj = this.hass.states[states.entity_id];
|
||||
const currentValue = stateObj ? safeParseFloat(stateObj.state) : null;
|
||||
if (currentValue !== null) {
|
||||
data[0].data!.push([now, currentValue]);
|
||||
trackY(currentValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Concat two arrays
|
||||
Array.prototype.push.apply(datasets, data);
|
||||
const data = generateStateHistoryChartLineData({
|
||||
hass: this.hass,
|
||||
data: this.data,
|
||||
endTime: this.endTime,
|
||||
names: this.names,
|
||||
colors: this.colors,
|
||||
showNames: this.showNames,
|
||||
computedStyles: getComputedStyle(this),
|
||||
now: new Date(),
|
||||
});
|
||||
|
||||
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
this._chartData = datasets;
|
||||
this._entityIds = entityIds;
|
||||
this._datasetToDataIndex = datasetToDataIndex;
|
||||
const visualMap: VisualMapComponentOption[] = [];
|
||||
this._chartData.forEach((_, seriesIndex) => {
|
||||
const dataIndex = this._datasetToDataIndex[seriesIndex];
|
||||
const data = this.data[dataIndex];
|
||||
if (!data.statistics || data.statistics.length === 0) {
|
||||
return;
|
||||
}
|
||||
// render stat data with a slightly transparent line
|
||||
const firstStateTS =
|
||||
data.states[0]?.last_changed ?? this.endTime.getTime();
|
||||
visualMap.push({
|
||||
show: false,
|
||||
seriesIndex,
|
||||
dimension: 0,
|
||||
pieces: [
|
||||
{
|
||||
max: firstStateTS - 0.01,
|
||||
colorAlpha: 0.5,
|
||||
},
|
||||
{
|
||||
min: firstStateTS,
|
||||
colorAlpha: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
this._visualMap = visualMap.length > 0 ? visualMap : undefined;
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._yAxisFractionDigits = data.yAxisFractionDigits;
|
||||
this._chartData = data.datasets;
|
||||
this._entityIds = data.entityIds;
|
||||
this._datasetToDataIndex = data.datasetToDataIndex;
|
||||
this._visualMap = data.visualMap;
|
||||
}
|
||||
|
||||
private _formatYAxisLabel = (value: number) => {
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
import type {
|
||||
BarSeriesOption,
|
||||
LineSeriesOption,
|
||||
ZRColor,
|
||||
} from "echarts/types/dist/shared";
|
||||
import { getGraphColorByIndex } from "../../common/color/colors";
|
||||
import type {
|
||||
Statistics,
|
||||
StatisticsMetaData,
|
||||
StatisticType,
|
||||
} from "../../data/recorder";
|
||||
import {
|
||||
getDisplayUnit,
|
||||
getStatisticLabel,
|
||||
isExternalStatistic,
|
||||
statisticsHaveType,
|
||||
} from "../../data/recorder";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { fillDataGapsAndRoundCaps } from "./round-caps";
|
||||
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
|
||||
|
||||
export interface StatisticsChartLegendItem {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: ZRColor;
|
||||
borderColor?: ZRColor;
|
||||
noLabelClick?: boolean;
|
||||
}
|
||||
|
||||
export interface StatisticsChartDataParams {
|
||||
hass: HomeAssistant;
|
||||
statisticsData: Statistics;
|
||||
statisticsMetaData: Record<string, StatisticsMetaData>;
|
||||
names?: Record<string, string>;
|
||||
colors?: Record<string, string | undefined>;
|
||||
unit?: string;
|
||||
endTime?: Date;
|
||||
statTypes: StatisticType[];
|
||||
chartType: "line" | "line-stack" | "bar" | "bar-stack";
|
||||
period?: string;
|
||||
hideLegend: boolean;
|
||||
hiddenStats: ReadonlySet<string>;
|
||||
computedStyle: CSSStyleDeclaration;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
export interface StatisticsChartData {
|
||||
datasets: (LineSeriesOption | BarSeriesOption)[];
|
||||
legendData: StatisticsChartLegendItem[];
|
||||
statisticIds: string[];
|
||||
/** Chart unit, inferred from statistics metadata when not set explicitly */
|
||||
unit?: string;
|
||||
yAxisFractionDigits: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms raw statistics into ECharts series for `statistics-chart`.
|
||||
* Pure data processing: all environment inputs (current time, theme style,
|
||||
* hass) are injected so the transform is deterministic and benchmarkable.
|
||||
*/
|
||||
export function generateStatisticsChartData(
|
||||
params: StatisticsChartDataParams
|
||||
): StatisticsChartData | undefined {
|
||||
const { hass, statisticsMetaData, computedStyle, now, hiddenStats } = params;
|
||||
|
||||
let colorIndex = 0;
|
||||
const chartType = params.chartType.startsWith("line") ? "line" : "bar";
|
||||
const chartStacked = params.chartType.endsWith("stack");
|
||||
const statisticsData = Object.entries(params.statisticsData);
|
||||
const totalDataSets: (LineSeriesOption | BarSeriesOption)[] = [];
|
||||
let yMin = Infinity;
|
||||
let yMax = -Infinity;
|
||||
const trackY = (v: number | null | undefined) => {
|
||||
if (typeof v === "number" && Number.isFinite(v)) {
|
||||
if (v < yMin) yMin = v;
|
||||
if (v > yMax) yMax = v;
|
||||
}
|
||||
};
|
||||
const legendData: StatisticsChartLegendItem[] = [];
|
||||
const statisticIds: string[] = [];
|
||||
let endTime: Date;
|
||||
|
||||
if (statisticsData.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
endTime =
|
||||
params.endTime ||
|
||||
// Get the highest date from the last date of each statistic
|
||||
new Date(
|
||||
Math.max(
|
||||
...statisticsData.map(([_, stats]) =>
|
||||
new Date(stats[stats.length - 1].start).getTime()
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
if (endTime > now) {
|
||||
endTime = now;
|
||||
}
|
||||
|
||||
// Check if we need to display most recent data. Allow 10m of leeway for "now",
|
||||
// because stats are 5 minute aggregated.
|
||||
// Use same now point for all statistics even if processing time means the
|
||||
// state value is actually from a slightly later time. Otherwise the points
|
||||
// end up separated slightly and disappear from the tooltips.
|
||||
const displayCurrentState = now.getTime() - endTime.getTime() <= 600000;
|
||||
|
||||
// Try to determine chart unit if it has not already been set explicitly
|
||||
let unit = params.unit;
|
||||
if (!unit) {
|
||||
let inferredUnit: string | undefined | null;
|
||||
statisticsData.forEach(([statistic_id, _stats]) => {
|
||||
const meta = statisticsMetaData?.[statistic_id];
|
||||
const statisticUnit = getDisplayUnit(hass, statistic_id, meta);
|
||||
if (inferredUnit === undefined) {
|
||||
inferredUnit = statisticUnit;
|
||||
} else if (inferredUnit !== null && inferredUnit !== statisticUnit) {
|
||||
// Clear unit if not all statistics have same unit
|
||||
inferredUnit = null;
|
||||
}
|
||||
});
|
||||
if (inferredUnit) {
|
||||
unit = inferredUnit;
|
||||
}
|
||||
}
|
||||
|
||||
const names = params.names || {};
|
||||
const colors = params.colors || {};
|
||||
statisticsData.forEach(([statistic_id, stats]) => {
|
||||
const meta = statisticsMetaData?.[statistic_id];
|
||||
let name = names[statistic_id];
|
||||
if (name === undefined) {
|
||||
name = getStatisticLabel(hass, statistic_id, meta);
|
||||
}
|
||||
|
||||
// array containing [value1, value2, etc]
|
||||
let prevValues: (number | null)[][] | null = null;
|
||||
let prevEndTime: Date | undefined;
|
||||
|
||||
// The datasets for the current statistic
|
||||
const statDataSets: (LineSeriesOption | BarSeriesOption)[] = [];
|
||||
const statLegendData: StatisticsChartLegendItem[] = [];
|
||||
|
||||
// Place bars at centre of their specified time range if this is a bar chart
|
||||
// and the period is 5minute or hour.
|
||||
const centerBars =
|
||||
chartType === "bar" &&
|
||||
(params.period === "5minute" || params.period === "hour");
|
||||
|
||||
const pushData = (
|
||||
start: Date, // Data point start time
|
||||
end: Date, // Data point end time
|
||||
limit: Date, // Limit for end time (e.g. now)
|
||||
dataValues: (number | null)[][]
|
||||
) => {
|
||||
if (!dataValues.length) return;
|
||||
// Limit for time range is lesser of overall limit and data point end
|
||||
limit = end.getTime() < limit.getTime() ? end : limit;
|
||||
if (start.getTime() > limit.getTime()) {
|
||||
// Drop data points that are after the requested endTime. This could happen if
|
||||
// endTime is "now" and client time is not in sync with server time.
|
||||
return;
|
||||
}
|
||||
const isLineChart = chartType === "line";
|
||||
// For bar charts, optionally center the bar within its time range. The
|
||||
// centered time is shared by every series of this data point.
|
||||
const barTime =
|
||||
!isLineChart && centerBars
|
||||
? new Date((start.getTime() + end.getTime()) / 2)
|
||||
: start;
|
||||
// Whether a gap needs to be drawn before this data point (line charts).
|
||||
const drawGap =
|
||||
isLineChart &&
|
||||
!!prevEndTime &&
|
||||
!!prevValues &&
|
||||
prevEndTime.getTime() !== start.getTime();
|
||||
for (let i = 0; i < statDataSets.length; i++) {
|
||||
const d = statDataSets[i];
|
||||
const dataValue = dataValues[i];
|
||||
if (isLineChart) {
|
||||
if (drawGap) {
|
||||
// if the end of the previous data doesn't match the start of the current data,
|
||||
// we have to draw a gap so add a value at the end time, and then an empty value.
|
||||
d.data!.push([prevEndTime!, ...prevValues![i]!]);
|
||||
d.data!.push([prevEndTime!, null]);
|
||||
}
|
||||
d.data!.push([start, ...dataValue!]);
|
||||
// For band-top rows dataValues[i] is [diff, top]; the actual Y is
|
||||
// the last element. For regular rows it's [value]. Same call works.
|
||||
trackY(dataValue[dataValue.length - 1]);
|
||||
} else {
|
||||
// Data value should always be a scalar for bar charts. Pass in
|
||||
// real start time as extra value to allow formatting tooltip.
|
||||
d.data!.push([barTime, dataValue[0]!, start, end]);
|
||||
trackY(dataValue[0]);
|
||||
}
|
||||
}
|
||||
prevValues = dataValues;
|
||||
prevEndTime = limit;
|
||||
};
|
||||
|
||||
let color = colors[statistic_id];
|
||||
if (color === undefined) {
|
||||
color = getGraphColorByIndex(colorIndex, computedStyle);
|
||||
colorIndex++;
|
||||
}
|
||||
|
||||
const statTypes: StatisticType[] = [];
|
||||
|
||||
const hasMean =
|
||||
params.statTypes.includes("mean") && statisticsHaveType(stats, "mean");
|
||||
const hasMax =
|
||||
params.statTypes.includes("max") && statisticsHaveType(stats, "max");
|
||||
const hasMin =
|
||||
params.statTypes.includes("min") && statisticsHaveType(stats, "min");
|
||||
const drawBands =
|
||||
!chartStacked && [hasMean, hasMax, hasMin].filter(Boolean).length > 1;
|
||||
|
||||
const hasState = params.statTypes.includes("state");
|
||||
|
||||
const bandTop = hasMax ? "max" : "mean";
|
||||
const bandBottom = hasMin ? "min" : "mean";
|
||||
|
||||
const sortedTypes = drawBands
|
||||
? [...params.statTypes].sort((a, b) => {
|
||||
if (a === "min" || b === "max") {
|
||||
return -1;
|
||||
}
|
||||
if (a === "max" || b === "min") {
|
||||
return +1;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
: params.statTypes;
|
||||
|
||||
let displayedLegend = false;
|
||||
sortedTypes.forEach((type) => {
|
||||
if (statisticsHaveType(stats, type)) {
|
||||
const band = drawBands && (type === bandTop || type === bandBottom);
|
||||
statTypes.push(type);
|
||||
const borderColor =
|
||||
(band && hasMin && hasMax && hasMean) ||
|
||||
(hasState && ["change", "sum"].includes(type))
|
||||
? color + (params.hideLegend ? "00" : "7F")
|
||||
: color;
|
||||
const backgroundColor = band ? color + "3F" : color + "7F";
|
||||
const series: LineSeriesOption | BarSeriesOption = {
|
||||
id: `${statistic_id}-${type}`,
|
||||
type: chartType,
|
||||
smooth: chartType === "line" ? 0.4 : false,
|
||||
cursor: "default",
|
||||
data: [],
|
||||
name: name
|
||||
? `${name} (${hass.localize(
|
||||
`ui.components.statistics_charts.statistic_types.${type}`
|
||||
)})`
|
||||
: hass.localize(
|
||||
`ui.components.statistics_charts.statistic_types.${type}`
|
||||
),
|
||||
symbol: "none",
|
||||
// minmax sampling operates independently per series, breaking stacking alignment
|
||||
// https://github.com/apache/echarts/issues/11879
|
||||
sampling: band && drawBands ? "lttb" : "minmax",
|
||||
animationDurationUpdate: 0,
|
||||
lineStyle: {
|
||||
width: 1.5,
|
||||
},
|
||||
itemStyle:
|
||||
chartType === "bar"
|
||||
? {
|
||||
borderColor,
|
||||
borderWidth: 1.5,
|
||||
}
|
||||
: undefined,
|
||||
color: chartType === "bar" ? backgroundColor : borderColor,
|
||||
};
|
||||
if (chartStacked) {
|
||||
series.stack = `band-stacked`;
|
||||
series.stackStrategy = "samesign";
|
||||
if (chartType === "line") {
|
||||
(series as LineSeriesOption).areaStyle = {
|
||||
color: color + "3F",
|
||||
};
|
||||
}
|
||||
} else if (band && chartType === "line") {
|
||||
series.stack = `band-${statistic_id}`;
|
||||
series.stackStrategy = "all";
|
||||
if (hiddenStats.has(`${statistic_id}-${bandBottom}`)) {
|
||||
// changing the stackOrder forces echarts to render the stacked series that are not hidden #28472
|
||||
series.stackOrder = "seriesDesc";
|
||||
(series as LineSeriesOption).areaStyle = undefined;
|
||||
} else {
|
||||
series.stackOrder = "seriesAsc";
|
||||
if (type === bandTop) {
|
||||
(series as LineSeriesOption).areaStyle = {
|
||||
color: color + "3F",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!params.hideLegend) {
|
||||
const showLegend = hasMean
|
||||
? type === "mean"
|
||||
: displayedLegend === false;
|
||||
if (showLegend) {
|
||||
statLegendData.push({
|
||||
id: statistic_id,
|
||||
name,
|
||||
color: series.color as ZRColor,
|
||||
borderColor: series.itemStyle?.borderColor,
|
||||
noLabelClick: isExternalStatistic(statistic_id),
|
||||
});
|
||||
}
|
||||
displayedLegend = displayedLegend || showLegend;
|
||||
}
|
||||
statDataSets.push(series);
|
||||
statisticIds.push(statistic_id);
|
||||
}
|
||||
});
|
||||
|
||||
let prevStart: number | null = null;
|
||||
// Process chart data.
|
||||
let firstSum: number | null | undefined = null;
|
||||
|
||||
// The per-type branch decisions in the inner loop are invariant across all
|
||||
// stats of this statistic, so classify each type once up front.
|
||||
// kind: 0 = sum (cumulative diff), 1 = band-top ([diff, top]), 2 = plain.
|
||||
const SUM_KIND = 0;
|
||||
const BAND_KIND = 1;
|
||||
const PLAIN_KIND = 2;
|
||||
const bandBottomHidden = hiddenStats.has(`${statistic_id}-${bandBottom}`);
|
||||
const isLine = chartType === "line";
|
||||
const typeKinds = statTypes.map((type) => {
|
||||
if (type === "sum") {
|
||||
return SUM_KIND;
|
||||
}
|
||||
if (type === bandTop && isLine && drawBands && !bandBottomHidden) {
|
||||
return BAND_KIND;
|
||||
}
|
||||
return PLAIN_KIND;
|
||||
});
|
||||
const numTypes = statTypes.length;
|
||||
const statHidden = hiddenStats.has(statistic_id);
|
||||
|
||||
for (const stat of stats) {
|
||||
// Skip consecutive stats that share the same start time. Compare the raw
|
||||
// numeric start so the dedup actually fires (a `Date` reference compare
|
||||
// never would) and so we skip allocating a `Date` on the dropped path.
|
||||
if (prevStart === stat.start) {
|
||||
continue;
|
||||
}
|
||||
prevStart = stat.start;
|
||||
const startDate = new Date(stat.start);
|
||||
const endDate = new Date(stat.end);
|
||||
const dataValues: (number | null)[][] = [];
|
||||
for (let t = 0; t < numTypes; t++) {
|
||||
const type = statTypes[t];
|
||||
const val: (number | null)[] = [];
|
||||
switch (typeKinds[t]) {
|
||||
case SUM_KIND:
|
||||
if (firstSum === null || firstSum === undefined) {
|
||||
val.push(0);
|
||||
firstSum = stat.sum;
|
||||
} else {
|
||||
val.push((stat.sum || 0) - firstSum);
|
||||
}
|
||||
break;
|
||||
case BAND_KIND: {
|
||||
const top = stat[bandTop] || 0;
|
||||
val.push(Math.abs(top - (stat[bandBottom] || 0)));
|
||||
val.push(top);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
val.push(stat[type] ?? null);
|
||||
}
|
||||
dataValues.push(val);
|
||||
}
|
||||
if (!statHidden) {
|
||||
pushData(startDate, endDate, endTime, dataValues);
|
||||
}
|
||||
}
|
||||
|
||||
// For line charts, close out the last stat segment at prevEndTime
|
||||
const lastEndTime = prevEndTime;
|
||||
const lastValues = prevValues;
|
||||
if (chartType === "line" && lastEndTime && lastValues) {
|
||||
statDataSets.forEach((d, i) => {
|
||||
d.data!.push([lastEndTime, ...lastValues[i]!]);
|
||||
});
|
||||
}
|
||||
|
||||
// Show current state if required, and units match (or are unknown)
|
||||
const statisticUnit = getDisplayUnit(hass, statistic_id, meta);
|
||||
if (
|
||||
displayCurrentState &&
|
||||
!chartStacked &&
|
||||
(!unit || !statisticUnit || unit === statisticUnit)
|
||||
) {
|
||||
// Skip external statistics
|
||||
if (!isExternalStatistic(statistic_id)) {
|
||||
const stateObj = hass.states[statistic_id];
|
||||
if (stateObj) {
|
||||
const currentValue = parseFloat(stateObj.state);
|
||||
if (isFinite(currentValue) && !hiddenStats.has(statistic_id)) {
|
||||
// Then push the current state at now
|
||||
statTypes.forEach((type, i) => {
|
||||
if (type === "sum" || type === "change") {
|
||||
// Skip cumulative types - need special calculation.
|
||||
return;
|
||||
}
|
||||
const val: (number | null)[] = [];
|
||||
if (
|
||||
type === bandTop &&
|
||||
chartType === "line" &&
|
||||
drawBands &&
|
||||
!hiddenStats.has(`${statistic_id}-${bandBottom}`)
|
||||
) {
|
||||
// For band chart, current value is both min and max, so diff is 0
|
||||
val.push(0);
|
||||
val.push(currentValue);
|
||||
} else {
|
||||
val.push(currentValue);
|
||||
}
|
||||
statDataSets[i].data!.push([now, ...val]);
|
||||
trackY(val[val.length - 1]);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Concat two arrays
|
||||
Array.prototype.push.apply(totalDataSets, statDataSets);
|
||||
Array.prototype.push.apply(legendData, statLegendData);
|
||||
});
|
||||
|
||||
if (chartType === "bar") {
|
||||
fillDataGapsAndRoundCaps(totalDataSets as BarSeriesOption[], chartStacked);
|
||||
}
|
||||
|
||||
legendData.forEach(({ id, name, color, borderColor }) => {
|
||||
// Add an empty series for the legend
|
||||
totalDataSets.push({
|
||||
id: id,
|
||||
name: name,
|
||||
color,
|
||||
itemStyle: {
|
||||
borderColor,
|
||||
},
|
||||
type: chartType,
|
||||
data: [],
|
||||
xAxisIndex: 1,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
datasets: totalDataSets,
|
||||
legendData,
|
||||
statisticIds,
|
||||
unit,
|
||||
yAxisFractionDigits: computeYAxisFractionDigits(yMin, yMax),
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import type {
|
||||
BarSeriesOption,
|
||||
LineSeriesOption,
|
||||
ZRColor,
|
||||
} from "echarts/types/dist/shared";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { getGraphColorByIndex } from "../../common/color/colors";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
@@ -27,13 +25,7 @@ import type {
|
||||
StatisticsMetaData,
|
||||
StatisticType,
|
||||
} from "../../data/recorder";
|
||||
import {
|
||||
getDisplayUnit,
|
||||
getStatisticLabel,
|
||||
getStatisticMetadata,
|
||||
isExternalStatistic,
|
||||
statisticsHaveType,
|
||||
} from "../../data/recorder";
|
||||
import { getStatisticMetadata, isExternalStatistic } from "../../data/recorder";
|
||||
import type { HaECOption } from "../../resources/echarts/echarts";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { getPeriodicAxisLabelConfig } from "./axis-label";
|
||||
@@ -41,8 +33,7 @@ import type { CustomLegendOption } from "./ha-chart-base";
|
||||
import "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import { fillDataGapsAndRoundCaps } from "./round-caps";
|
||||
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
|
||||
import { generateStatisticsChartData } from "./statistics-chart-data";
|
||||
|
||||
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
|
||||
mean: "mean",
|
||||
@@ -503,391 +494,35 @@ export class StatisticsChart extends LitElement {
|
||||
this.metadata ||
|
||||
(await this._getStatisticsMetaData(Object.keys(this.statisticsData)));
|
||||
|
||||
let colorIndex = 0;
|
||||
const chartType = this.chartType.startsWith("line") ? "line" : "bar";
|
||||
const chartStacked = this.chartType.endsWith("stack");
|
||||
const statisticsData = Object.entries(this.statisticsData);
|
||||
const totalDataSets: typeof this._chartData = [];
|
||||
let yMin = Infinity;
|
||||
let yMax = -Infinity;
|
||||
const trackY = (v: number | null | undefined) => {
|
||||
if (typeof v === "number" && Number.isFinite(v)) {
|
||||
if (v < yMin) yMin = v;
|
||||
if (v > yMax) yMax = v;
|
||||
}
|
||||
};
|
||||
const legendData: {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: ZRColor;
|
||||
borderColor?: ZRColor;
|
||||
noLabelClick?: boolean;
|
||||
}[] = [];
|
||||
const statisticIds: string[] = [];
|
||||
let endTime: Date;
|
||||
const data = generateStatisticsChartData({
|
||||
hass: this.hass,
|
||||
statisticsData: this.statisticsData,
|
||||
statisticsMetaData,
|
||||
names: this.names,
|
||||
colors: this.colors,
|
||||
unit: this.unit,
|
||||
endTime: this.endTime,
|
||||
statTypes: this.statTypes,
|
||||
chartType: this.chartType,
|
||||
period: this.period,
|
||||
hideLegend: this.hideLegend,
|
||||
hiddenStats: this._hiddenStats,
|
||||
computedStyle: this._computedStyle || getComputedStyle(this),
|
||||
now: new Date(),
|
||||
});
|
||||
|
||||
if (statisticsData.length === 0) {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
endTime =
|
||||
this.endTime ||
|
||||
// Get the highest date from the last date of each statistic
|
||||
new Date(
|
||||
Math.max(
|
||||
...statisticsData.map(([_, stats]) =>
|
||||
new Date(stats[stats.length - 1].start).getTime()
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
if (endTime > new Date()) {
|
||||
endTime = new Date();
|
||||
}
|
||||
|
||||
// Check if we need to display most recent data. Allow 10m of leeway for "now",
|
||||
// because stats are 5 minute aggregated.
|
||||
// Use same now point for all statistics even if processing time means the
|
||||
// state value is actually from a slightly later time. Otherwise the points
|
||||
// end up separated slightly and disappear from the tooltips.
|
||||
const now = new Date();
|
||||
const displayCurrentState = now.getTime() - endTime.getTime() <= 600000;
|
||||
|
||||
// Try to determine chart unit if it has not already been set explicitly
|
||||
if (!this.unit) {
|
||||
let unit: string | undefined | null;
|
||||
statisticsData.forEach(([statistic_id, _stats]) => {
|
||||
const meta = statisticsMetaData?.[statistic_id];
|
||||
const statisticUnit = getDisplayUnit(this.hass, statistic_id, meta);
|
||||
if (unit === undefined) {
|
||||
unit = statisticUnit;
|
||||
} else if (unit !== null && unit !== statisticUnit) {
|
||||
// Clear unit if not all statistics have same unit
|
||||
unit = null;
|
||||
}
|
||||
});
|
||||
if (unit) {
|
||||
this.unit = unit;
|
||||
}
|
||||
}
|
||||
|
||||
const names = this.names || {};
|
||||
const colors = this.colors || {};
|
||||
statisticsData.forEach(([statistic_id, stats]) => {
|
||||
const meta = statisticsMetaData?.[statistic_id];
|
||||
let name = names[statistic_id];
|
||||
if (name === undefined) {
|
||||
name = getStatisticLabel(this.hass, statistic_id, meta);
|
||||
}
|
||||
|
||||
// array containing [value1, value2, etc]
|
||||
let prevValues: (number | null)[][] | null = null;
|
||||
let prevEndTime: Date | undefined;
|
||||
|
||||
// The datasets for the current statistic
|
||||
const statDataSets: (LineSeriesOption | BarSeriesOption)[] = [];
|
||||
const statLegendData: typeof legendData = [];
|
||||
|
||||
// Place bars at centre of their specified time range if this is a bar chart
|
||||
// and the period is 5minute or hour.
|
||||
const centerBars =
|
||||
chartType === "bar" &&
|
||||
(this.period === "5minute" || this.period === "hour");
|
||||
|
||||
const pushData = (
|
||||
start: Date, // Data point start time
|
||||
end: Date, // Data point end time
|
||||
limit: Date, // Limit for end time (e.g. now)
|
||||
dataValues: (number | null)[][]
|
||||
) => {
|
||||
if (!dataValues.length) return;
|
||||
// Limit for time range is lesser of overall limit and data point end
|
||||
limit = end.getTime() < limit.getTime() ? end : limit;
|
||||
if (start.getTime() > limit.getTime()) {
|
||||
// Drop data points that are after the requested endTime. This could happen if
|
||||
// endTime is "now" and client time is not in sync with server time.
|
||||
return;
|
||||
}
|
||||
statDataSets.forEach((d, i) => {
|
||||
if (chartType === "line") {
|
||||
if (
|
||||
prevEndTime &&
|
||||
prevValues &&
|
||||
prevEndTime.getTime() !== start.getTime()
|
||||
) {
|
||||
// if the end of the previous data doesn't match the start of the current data,
|
||||
// we have to draw a gap so add a value at the end time, and then an empty value.
|
||||
d.data!.push([prevEndTime, ...prevValues[i]!]);
|
||||
d.data!.push([prevEndTime, null]);
|
||||
}
|
||||
d.data!.push([start, ...dataValues[i]!]);
|
||||
// For band-top rows dataValues[i] is [diff, top]; the actual Y is
|
||||
// the last element. For regular rows it's [value]. Same call works.
|
||||
trackY(dataValues[i][dataValues[i].length - 1]);
|
||||
} else {
|
||||
let time = start;
|
||||
if (centerBars) {
|
||||
// If centering bars, set the time to the midpoint between start and end instead
|
||||
// of the start time.
|
||||
time = new Date((start.getTime() + end.getTime()) / 2);
|
||||
}
|
||||
// Data value should always be a scalar for bar charts. Pass in
|
||||
// real start time as extra value to allow formatting tooltip.
|
||||
d.data!.push([time, dataValues[i][0]!, start, end]);
|
||||
trackY(dataValues[i][0]);
|
||||
}
|
||||
});
|
||||
prevValues = dataValues;
|
||||
prevEndTime = limit;
|
||||
};
|
||||
|
||||
let color = colors[statistic_id];
|
||||
if (color === undefined) {
|
||||
color = getGraphColorByIndex(
|
||||
colorIndex,
|
||||
this._computedStyle || getComputedStyle(this)
|
||||
);
|
||||
colorIndex++;
|
||||
}
|
||||
|
||||
const statTypes: this["statTypes"] = [];
|
||||
|
||||
const hasMean =
|
||||
this.statTypes.includes("mean") && statisticsHaveType(stats, "mean");
|
||||
const hasMax =
|
||||
this.statTypes.includes("max") && statisticsHaveType(stats, "max");
|
||||
const hasMin =
|
||||
this.statTypes.includes("min") && statisticsHaveType(stats, "min");
|
||||
const drawBands =
|
||||
!chartStacked && [hasMean, hasMax, hasMin].filter(Boolean).length > 1;
|
||||
|
||||
const hasState = this.statTypes.includes("state");
|
||||
|
||||
const bandTop = hasMax ? "max" : "mean";
|
||||
const bandBottom = hasMin ? "min" : "mean";
|
||||
|
||||
const sortedTypes = drawBands
|
||||
? [...this.statTypes].sort((a, b) => {
|
||||
if (a === "min" || b === "max") {
|
||||
return -1;
|
||||
}
|
||||
if (a === "max" || b === "min") {
|
||||
return +1;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
: this.statTypes;
|
||||
|
||||
let displayedLegend = false;
|
||||
sortedTypes.forEach((type) => {
|
||||
if (statisticsHaveType(stats, type)) {
|
||||
const band = drawBands && (type === bandTop || type === bandBottom);
|
||||
statTypes.push(type);
|
||||
const borderColor =
|
||||
(band && hasMin && hasMax && hasMean) ||
|
||||
(hasState && ["change", "sum"].includes(type))
|
||||
? color + (this.hideLegend ? "00" : "7F")
|
||||
: color;
|
||||
const backgroundColor = band ? color + "3F" : color + "7F";
|
||||
const series: LineSeriesOption | BarSeriesOption = {
|
||||
id: `${statistic_id}-${type}`,
|
||||
type: chartType,
|
||||
smooth: chartType === "line" ? 0.4 : false,
|
||||
cursor: "default",
|
||||
data: [],
|
||||
name: name
|
||||
? `${name} (${this.hass.localize(
|
||||
`ui.components.statistics_charts.statistic_types.${type}`
|
||||
)})`
|
||||
: this.hass.localize(
|
||||
`ui.components.statistics_charts.statistic_types.${type}`
|
||||
),
|
||||
symbol: "none",
|
||||
// minmax sampling operates independently per series, breaking stacking alignment
|
||||
// https://github.com/apache/echarts/issues/11879
|
||||
sampling: band && drawBands ? "lttb" : "minmax",
|
||||
animationDurationUpdate: 0,
|
||||
lineStyle: {
|
||||
width: 1.5,
|
||||
},
|
||||
itemStyle:
|
||||
chartType === "bar"
|
||||
? {
|
||||
borderColor,
|
||||
borderWidth: 1.5,
|
||||
}
|
||||
: undefined,
|
||||
color: chartType === "bar" ? backgroundColor : borderColor,
|
||||
};
|
||||
if (chartStacked) {
|
||||
series.stack = `band-stacked`;
|
||||
series.stackStrategy = "samesign";
|
||||
if (chartType === "line") {
|
||||
(series as LineSeriesOption).areaStyle = {
|
||||
color: color + "3F",
|
||||
};
|
||||
}
|
||||
} else if (band && chartType === "line") {
|
||||
series.stack = `band-${statistic_id}`;
|
||||
series.stackStrategy = "all";
|
||||
if (this._hiddenStats.has(`${statistic_id}-${bandBottom}`)) {
|
||||
// changing the stackOrder forces echarts to render the stacked series that are not hidden #28472
|
||||
series.stackOrder = "seriesDesc";
|
||||
(series as LineSeriesOption).areaStyle = undefined;
|
||||
} else {
|
||||
series.stackOrder = "seriesAsc";
|
||||
if (type === bandTop) {
|
||||
(series as LineSeriesOption).areaStyle = {
|
||||
color: color + "3F",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!this.hideLegend) {
|
||||
const showLegend = hasMean
|
||||
? type === "mean"
|
||||
: displayedLegend === false;
|
||||
if (showLegend) {
|
||||
statLegendData.push({
|
||||
id: statistic_id,
|
||||
name,
|
||||
color: series.color as ZRColor,
|
||||
borderColor: series.itemStyle?.borderColor,
|
||||
noLabelClick: isExternalStatistic(statistic_id),
|
||||
});
|
||||
}
|
||||
displayedLegend = displayedLegend || showLegend;
|
||||
}
|
||||
statDataSets.push(series);
|
||||
statisticIds.push(statistic_id);
|
||||
}
|
||||
});
|
||||
|
||||
let prevDate: Date | null = null;
|
||||
// Process chart data.
|
||||
let firstSum: number | null | undefined = null;
|
||||
stats.forEach((stat) => {
|
||||
const startDate = new Date(stat.start);
|
||||
const endDate = new Date(stat.end);
|
||||
if (prevDate === startDate) {
|
||||
return;
|
||||
}
|
||||
prevDate = startDate;
|
||||
const dataValues: (number | null)[][] = [];
|
||||
statTypes.forEach((type) => {
|
||||
const val: (number | null)[] = [];
|
||||
if (type === "sum") {
|
||||
if (firstSum === null || firstSum === undefined) {
|
||||
val.push(0);
|
||||
firstSum = stat.sum;
|
||||
} else {
|
||||
val.push((stat.sum || 0) - firstSum);
|
||||
}
|
||||
} else if (
|
||||
type === bandTop &&
|
||||
chartType === "line" &&
|
||||
drawBands &&
|
||||
!this._hiddenStats.has(`${statistic_id}-${bandBottom}`)
|
||||
) {
|
||||
const top = stat[bandTop] || 0;
|
||||
val.push(Math.abs(top - (stat[bandBottom] || 0)));
|
||||
val.push(top);
|
||||
} else {
|
||||
val.push(stat[type] ?? null);
|
||||
}
|
||||
dataValues.push(val);
|
||||
});
|
||||
if (!this._hiddenStats.has(statistic_id)) {
|
||||
pushData(startDate, endDate, endTime, dataValues);
|
||||
}
|
||||
});
|
||||
|
||||
// For line charts, close out the last stat segment at prevEndTime
|
||||
const lastEndTime = prevEndTime;
|
||||
const lastValues = prevValues;
|
||||
if (chartType === "line" && lastEndTime && lastValues) {
|
||||
statDataSets.forEach((d, i) => {
|
||||
d.data!.push([lastEndTime, ...lastValues[i]!]);
|
||||
});
|
||||
}
|
||||
|
||||
// Show current state if required, and units match (or are unknown)
|
||||
const statisticUnit = getDisplayUnit(this.hass, statistic_id, meta);
|
||||
if (
|
||||
displayCurrentState &&
|
||||
!chartStacked &&
|
||||
(!this.unit || !statisticUnit || this.unit === statisticUnit)
|
||||
) {
|
||||
// Skip external statistics
|
||||
if (!isExternalStatistic(statistic_id)) {
|
||||
const stateObj = this.hass.states[statistic_id];
|
||||
if (stateObj) {
|
||||
const currentValue = parseFloat(stateObj.state);
|
||||
if (
|
||||
isFinite(currentValue) &&
|
||||
!this._hiddenStats.has(statistic_id)
|
||||
) {
|
||||
// Then push the current state at now
|
||||
statTypes.forEach((type, i) => {
|
||||
if (type === "sum" || type === "change") {
|
||||
// Skip cumulative types - need special calculation.
|
||||
return;
|
||||
}
|
||||
const val: (number | null)[] = [];
|
||||
if (
|
||||
type === bandTop &&
|
||||
chartType === "line" &&
|
||||
drawBands &&
|
||||
!this._hiddenStats.has(`${statistic_id}-${bandBottom}`)
|
||||
) {
|
||||
// For band chart, current value is both min and max, so diff is 0
|
||||
val.push(0);
|
||||
val.push(currentValue);
|
||||
} else {
|
||||
val.push(currentValue);
|
||||
}
|
||||
statDataSets[i].data!.push([now, ...val]);
|
||||
trackY(val[val.length - 1]);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Concat two arrays
|
||||
Array.prototype.push.apply(totalDataSets, statDataSets);
|
||||
Array.prototype.push.apply(legendData, statLegendData);
|
||||
});
|
||||
|
||||
if (chartType === "bar") {
|
||||
fillDataGapsAndRoundCaps(
|
||||
totalDataSets as BarSeriesOption[],
|
||||
chartStacked
|
||||
);
|
||||
}
|
||||
|
||||
legendData.forEach(({ id, name, color, borderColor }) => {
|
||||
// Add an empty series for the legend
|
||||
totalDataSets.push({
|
||||
id: id,
|
||||
name: name,
|
||||
color,
|
||||
itemStyle: {
|
||||
borderColor,
|
||||
},
|
||||
type: chartType,
|
||||
data: [],
|
||||
xAxisIndex: 1,
|
||||
});
|
||||
});
|
||||
|
||||
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
this._chartData = totalDataSets;
|
||||
if (legendData.length !== this._legendData?.length) {
|
||||
this.unit = data.unit;
|
||||
this._yAxisFractionDigits = data.yAxisFractionDigits;
|
||||
this._chartData = data.datasets;
|
||||
if (data.legendData.length !== this._legendData?.length) {
|
||||
// only update the legend if it has changed or it will trigger options update
|
||||
this._legendData =
|
||||
legendData.length > 1
|
||||
? legendData.map(({ id, name, noLabelClick }) => ({
|
||||
data.legendData.length > 1
|
||||
? data.legendData.map(({ id, name, noLabelClick }) => ({
|
||||
id,
|
||||
name,
|
||||
noLabelClick,
|
||||
@@ -895,7 +530,7 @@ export class StatisticsChart extends LitElement {
|
||||
: // if there is only one entity, let the base chart handle the legend
|
||||
undefined;
|
||||
}
|
||||
this._statisticIds = statisticIds;
|
||||
this._statisticIds = data.statisticIds;
|
||||
}
|
||||
|
||||
private _clampYAxis(value?: number | ((values: any) => number)) {
|
||||
|
||||
@@ -215,10 +215,16 @@ export class HaDataTable extends LitElement {
|
||||
if (clear) {
|
||||
this._checkedRows = [];
|
||||
}
|
||||
// Map + Set keep a large selection O(rows + ids) instead of O(rows × ids).
|
||||
const rowLookup = new Map(
|
||||
(this._filteredData || []).map((data) => [data[this.id], data])
|
||||
);
|
||||
const checkedRows = new Set(this._checkedRows);
|
||||
ids.forEach((id) => {
|
||||
const row = this._filteredData?.find((data) => data[this.id] === id);
|
||||
if (row?.selectable !== false && !this._checkedRows.includes(id)) {
|
||||
const row = rowLookup.get(id);
|
||||
if (row?.selectable !== false && !checkedRows.has(id)) {
|
||||
this._checkedRows.push(id);
|
||||
checkedRows.add(id);
|
||||
}
|
||||
});
|
||||
this._lastSelectedRowId = null;
|
||||
|
||||
@@ -112,12 +112,16 @@ export class HaCameraStream extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
if (stream.type === MJPEG_STREAM) {
|
||||
const streamUrl = __DEMO__
|
||||
? this.stateObj.attributes.entity_picture
|
||||
: this._connected
|
||||
? computeMJPEGStreamUrl(this.stateObj)
|
||||
: this._posterUrl;
|
||||
if (!streamUrl) {
|
||||
return nothing;
|
||||
}
|
||||
return html`<img
|
||||
.src=${__DEMO__
|
||||
? this.stateObj.attributes.entity_picture!
|
||||
: this._connected
|
||||
? computeMJPEGStreamUrl(this.stateObj)
|
||||
: this._posterUrl || ""}
|
||||
.src=${streamUrl}
|
||||
style=${styleMap({
|
||||
aspectRatio: this.aspectRatio,
|
||||
objectFit: this.fitMode,
|
||||
|
||||
@@ -183,6 +183,7 @@ export class HaControlSelectMenu extends LitElement {
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
font-family: var(--ha-font-family-body, inherit);
|
||||
font-style: normal;
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
letter-spacing: 0.25px;
|
||||
|
||||
@@ -12,6 +12,20 @@ import type {
|
||||
HaFormSelectSchema,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* The underlying select returns option values as strings. Map a selected value
|
||||
* back to its original option value so the source type is retained (for example
|
||||
* a number coming from a backend `vol.In` schema), falling back to the value
|
||||
* itself when no option matches.
|
||||
*/
|
||||
export const matchSelectOptionValue = (
|
||||
options: HaFormSelectSchema["options"],
|
||||
value: string
|
||||
): HaFormSelectData => {
|
||||
const option = options.find((opt) => String(opt[0]) === String(value));
|
||||
return option ? option[0] : value;
|
||||
};
|
||||
|
||||
@customElement("ha-form-select")
|
||||
export class HaFormSelect extends LitElement implements HaFormElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -66,14 +80,16 @@ export class HaFormSelect extends LitElement implements HaFormElement {
|
||||
|
||||
private _valueChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
let value: string | undefined = ev.detail.value;
|
||||
|
||||
if (value === this.data) {
|
||||
return;
|
||||
}
|
||||
let value: HaFormSelectData | undefined = ev.detail.value;
|
||||
|
||||
if (value === "") {
|
||||
value = undefined;
|
||||
} else if (value != null) {
|
||||
value = matchSelectOptionValue(this.schema.options, value);
|
||||
}
|
||||
|
||||
if (value === this.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
fireEvent(this, "value-changed", {
|
||||
|
||||
@@ -41,6 +41,8 @@ const CUSTOM_ICONS: Record<string, () => Promise<string>> = {
|
||||
),
|
||||
esphome: () =>
|
||||
import("../resources/esphome-logo-svg").then((mod) => mod.mdiEsphomeLogo),
|
||||
matter: () =>
|
||||
import("../resources/matter-logo-svg").then((mod) => mod.mdiMatterLogo),
|
||||
};
|
||||
|
||||
@customElement("ha-icon")
|
||||
|
||||
@@ -354,7 +354,9 @@ export class HaSerialPortSelector extends LitElement {
|
||||
}
|
||||
|
||||
private get _selectorDomain(): string | undefined {
|
||||
return this.context?.handler;
|
||||
// `domain` is the integration domain even in options flows, where the flow
|
||||
// handler is the config entry id instead.
|
||||
return this.context?.domain;
|
||||
}
|
||||
|
||||
private _memoRecommendedDomains = memoizeOne(
|
||||
|
||||
@@ -2,12 +2,19 @@ import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { goBack } from "../common/navigate";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import "./ha-icon-button-arrow-prev";
|
||||
import "./ha-menu-button";
|
||||
|
||||
const PASSIVE_EVENT_OPTIONS = { passive: true } as const;
|
||||
|
||||
export const haTopAppBarFixedStyles = css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
--total-top-app-bar-height: calc(
|
||||
var(--header-height, 0px) + var(--sub-row-height, 0px)
|
||||
);
|
||||
@@ -18,10 +25,11 @@ export const haTopAppBarFixedStyles = css`
|
||||
box-sizing: border-box;
|
||||
color: var(--app-header-text-color, #fff);
|
||||
background-color: var(--app-header-background-color, var(--primary-color));
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
width: var(--ha-top-app-bar-width, 100%);
|
||||
width: 100%;
|
||||
z-index: 4;
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
@@ -113,17 +121,17 @@ export const haTopAppBarFixedStyles = css`
|
||||
}
|
||||
|
||||
.top-app-bar-fixed-adjust {
|
||||
height: calc(
|
||||
100vh - var(--total-top-app-bar-height, 0px) - var(
|
||||
--safe-area-inset-top,
|
||||
0px
|
||||
) - var(--safe-area-inset-bottom, 0px)
|
||||
);
|
||||
padding-top: calc(
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
top: calc(
|
||||
var(--total-top-app-bar-height, 0px) + var(--safe-area-inset-top, 0px)
|
||||
);
|
||||
bottom: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:host([narrow]) .top-app-bar-fixed-adjust {
|
||||
@@ -135,12 +143,16 @@ export const haTopAppBarFixedStyles = css`
|
||||
export class HaTopAppBarFixed extends LitElement {
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@property({ attribute: "back-button", type: Boolean }) backButton = false;
|
||||
|
||||
@property({ attribute: "center-title", type: Boolean }) centerTitle = false;
|
||||
|
||||
@query(".top-app-bar") protected _barElement!: HTMLElement;
|
||||
|
||||
@query(".sub-row") protected _subRowElement?: HTMLElement;
|
||||
|
||||
@query(".top-app-bar-fixed-adjust") protected _scrollElement?: HTMLElement;
|
||||
|
||||
@state() private _hasSubRow = false;
|
||||
|
||||
private _scrollTarget?: HTMLElement | Window;
|
||||
@@ -149,14 +161,13 @@ export class HaTopAppBarFixed extends LitElement {
|
||||
|
||||
@property({ attribute: false })
|
||||
public get scrollTarget(): HTMLElement | Window {
|
||||
return this._scrollTarget || window;
|
||||
return this._scrollTarget || this._scrollElement || window;
|
||||
}
|
||||
|
||||
public set scrollTarget(value: HTMLElement | Window) {
|
||||
const old = this.scrollTarget;
|
||||
this._unregisterListeners();
|
||||
this._scrollTarget = value;
|
||||
this._updateBarPosition();
|
||||
this.requestUpdate("scrollTarget", old);
|
||||
if (this.isConnected) {
|
||||
this._registerListeners();
|
||||
@@ -178,7 +189,6 @@ export class HaTopAppBarFixed extends LitElement {
|
||||
if (this.hasUpdated) {
|
||||
this._observeSubRowHeight();
|
||||
this._updateSubRowHeight();
|
||||
this._updateBarPosition();
|
||||
this._registerListeners();
|
||||
this._syncScrollState();
|
||||
}
|
||||
@@ -200,16 +210,14 @@ export class HaTopAppBarFixed extends LitElement {
|
||||
<div class="row">
|
||||
${paneHeader
|
||||
? html`<section class="section" id="title">
|
||||
<slot name="navigationIcon"></slot>
|
||||
${title}
|
||||
${this._renderNavigationIcon()} ${title}
|
||||
</section>`
|
||||
: nothing}
|
||||
<section class="section" id="navigation">
|
||||
${paneHeader
|
||||
? nothing
|
||||
: html`<slot name="navigationIcon"></slot> ${this.centerTitle
|
||||
? nothing
|
||||
: title}`}
|
||||
: html`${this._renderNavigationIcon()}
|
||||
${this.centerTitle ? nothing : title}`}
|
||||
</section>
|
||||
${!paneHeader && this.centerTitle
|
||||
? html`<section class="section center">${title}</section>`
|
||||
@@ -225,8 +233,22 @@ export class HaTopAppBarFixed extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderNavigationIcon() {
|
||||
return html`
|
||||
<slot name="navigationIcon">
|
||||
${this.backButton
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
@click=${this._handleBackClick}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`<ha-menu-button></ha-menu-button>`}
|
||||
</slot>
|
||||
`;
|
||||
}
|
||||
|
||||
protected _renderContent() {
|
||||
return html`<div class="top-app-bar-fixed-adjust">
|
||||
return html`<div class="top-app-bar-fixed-adjust ha-scrollbar">
|
||||
<slot></slot>
|
||||
</div>`;
|
||||
}
|
||||
@@ -235,7 +257,6 @@ export class HaTopAppBarFixed extends LitElement {
|
||||
super.firstUpdated(changedProperties);
|
||||
this._observeSubRowHeight();
|
||||
this._updateSubRowHeight();
|
||||
this._updateBarPosition();
|
||||
this._registerListeners();
|
||||
this._syncScrollState();
|
||||
}
|
||||
@@ -253,13 +274,6 @@ export class HaTopAppBarFixed extends LitElement {
|
||||
this._unregisterListeners();
|
||||
}
|
||||
|
||||
protected _updateBarPosition() {
|
||||
if (this._barElement) {
|
||||
this._barElement.style.position =
|
||||
this.scrollTarget === window ? "" : "absolute";
|
||||
}
|
||||
}
|
||||
|
||||
protected _syncScrollState = () => {
|
||||
const scrollTop =
|
||||
this.scrollTarget instanceof Window
|
||||
@@ -268,6 +282,11 @@ export class HaTopAppBarFixed extends LitElement {
|
||||
this._barElement?.classList.toggle("scrolled", scrollTop > 0);
|
||||
};
|
||||
|
||||
private _handleBackClick(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
goBack();
|
||||
}
|
||||
|
||||
protected _registerListeners() {
|
||||
this.scrollTarget.addEventListener(
|
||||
"scroll",
|
||||
@@ -314,7 +333,10 @@ export class HaTopAppBarFixed extends LitElement {
|
||||
this.style.setProperty("--sub-row-height", `${subRowHeight}px`);
|
||||
};
|
||||
|
||||
static override styles: CSSResultGroup = haTopAppBarFixedStyles;
|
||||
static override styles: CSSResultGroup = [
|
||||
haStyleScrollbar,
|
||||
haTopAppBarFixedStyles,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -85,15 +85,25 @@ export class HaTTSVoicePicker extends LitElement {
|
||||
await listTTSVoices(this.hass, this.engineId, this.language)
|
||||
).voices;
|
||||
|
||||
if (!this.value) {
|
||||
const valueIsValid =
|
||||
this.value &&
|
||||
this._voices?.some((voice) => voice.voice_id === this.value);
|
||||
|
||||
if (valueIsValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!this._voices ||
|
||||
!this._voices.find((voice) => voice.voice_id === this.value)
|
||||
) {
|
||||
this.value = undefined;
|
||||
// The current value is missing or no longer valid for the loaded voices.
|
||||
// When a voice is required, auto-select the first one (the <ha-select>
|
||||
// already displays it) so the value is propagated to the parent;
|
||||
// otherwise clear it.
|
||||
const newValue =
|
||||
this.required && this._voices?.length
|
||||
? this._voices[0].voice_id
|
||||
: undefined;
|
||||
|
||||
if (newValue !== this.value) {
|
||||
this.value = newValue;
|
||||
fireEvent(this, "value-changed", { value: this.value });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
|
||||
<div
|
||||
class=${classMap({
|
||||
"top-app-bar-fixed-adjust": true,
|
||||
"ha-scrollbar": true,
|
||||
"top-app-bar-fixed-adjust--pane": this.pane,
|
||||
})}
|
||||
>
|
||||
@@ -130,12 +131,7 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
|
||||
|
||||
.top-app-bar-fixed-adjust--pane {
|
||||
display: flex;
|
||||
height: calc(
|
||||
100vh - var(--total-top-app-bar-height, 0px) - var(
|
||||
--safe-area-inset-top,
|
||||
0px
|
||||
) - var(--safe-area-inset-bottom, 0px)
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pane {
|
||||
@@ -167,6 +163,7 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.top-app-bar-fixed-adjust--pane .content {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import timezones from "google-timezones-json";
|
||||
|
||||
export const createTimezoneListEl = () => {
|
||||
const list = document.createElement("datalist");
|
||||
list.id = "timezones";
|
||||
Object.keys(timezones).forEach((key) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = key;
|
||||
option.innerText = timezones[key];
|
||||
list.appendChild(option);
|
||||
});
|
||||
return list;
|
||||
};
|
||||
+1
-1
@@ -87,7 +87,7 @@ export const redirectWithAuthCode = (
|
||||
// OAuth 2: 3.1.2 we need to retain query component of a redirect URI
|
||||
if (!url.includes("?")) {
|
||||
url += "?";
|
||||
} else if (!url.endsWith("&")) {
|
||||
} else if (!url.endsWith("?") && !url.endsWith("&")) {
|
||||
url += "&";
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -181,7 +181,7 @@ export interface RestoreBackupParams {
|
||||
restore_homeassistant?: boolean;
|
||||
}
|
||||
|
||||
export const fetchBackupConfig = (hass: HomeAssistant) =>
|
||||
export const fetchBackupConfig = (hass: Pick<HomeAssistant, "callWS">) =>
|
||||
hass.callWS<{ config: BackupConfig }>({ type: "backup/config/info" });
|
||||
|
||||
export const updateBackupConfig = (
|
||||
|
||||
+7
-3
@@ -17,7 +17,7 @@ export type StreamType = typeof STREAM_TYPE_HLS | typeof STREAM_TYPE_WEB_RTC;
|
||||
|
||||
interface CameraEntityAttributes extends HassEntityAttributeBase {
|
||||
model_name: string;
|
||||
access_token: string;
|
||||
access_token?: string;
|
||||
brand: string;
|
||||
motion_detection: boolean;
|
||||
frontend_stream_type: string;
|
||||
@@ -78,8 +78,12 @@ export const cameraUrlWithWidthHeight = (
|
||||
height: number
|
||||
) => `${base_url}&width=${width}&height=${height}`;
|
||||
|
||||
export const computeMJPEGStreamUrl = (entity: CameraEntity) =>
|
||||
`/api/camera_proxy_stream/${entity.entity_id}?token=${entity.attributes.access_token}`;
|
||||
export const computeMJPEGStreamUrl = (
|
||||
entity: CameraEntity
|
||||
): string | undefined =>
|
||||
entity.attributes.access_token
|
||||
? `/api/camera_proxy_stream/${entity.entity_id}?token=${entity.attributes.access_token}`
|
||||
: undefined;
|
||||
|
||||
export const fetchThumbnailUrlWithCache = async (
|
||||
hass: HomeAssistant,
|
||||
|
||||
+20
-16
@@ -1121,14 +1121,12 @@ const getSummedDataPartial = (
|
||||
const timestamps = new Set<number>();
|
||||
Object.entries(statIds).forEach(([key, subStatIds]) => {
|
||||
const totalStats: Record<number, number> = {};
|
||||
const sets: Record<string, Record<number, number>> = {};
|
||||
let sum = 0;
|
||||
subStatIds!.forEach((id) => {
|
||||
const stats = compare ? data.statsCompare[id] : data.stats[id];
|
||||
if (!stats) {
|
||||
return;
|
||||
}
|
||||
const set = {};
|
||||
stats.forEach((stat) => {
|
||||
if (stat.change === null || stat.change === undefined) {
|
||||
return;
|
||||
@@ -1139,7 +1137,6 @@ const getSummedDataPartial = (
|
||||
stat.start in totalStats ? totalStats[stat.start] + val : val;
|
||||
timestamps.add(stat.start);
|
||||
});
|
||||
sets[id] = set;
|
||||
});
|
||||
summedData[key] = totalStats;
|
||||
summedData.total[key] = sum;
|
||||
@@ -1190,6 +1187,13 @@ const computeConsumptionDataPartial = (
|
||||
},
|
||||
};
|
||||
|
||||
const fromGrid = data.from_grid;
|
||||
const toGrid = data.to_grid;
|
||||
const solarData = data.solar;
|
||||
const toBattery = data.to_battery;
|
||||
const fromBattery = data.from_battery;
|
||||
const total = outData.total;
|
||||
|
||||
data.timestamps.forEach((t) => {
|
||||
const {
|
||||
grid_to_battery,
|
||||
@@ -1201,29 +1205,29 @@ const computeConsumptionDataPartial = (
|
||||
solar_to_battery,
|
||||
solar_to_grid,
|
||||
} = computeConsumptionSingle({
|
||||
from_grid: data.from_grid && (data.from_grid[t] ?? 0),
|
||||
to_grid: data.to_grid && (data.to_grid[t] ?? 0),
|
||||
solar: data.solar && (data.solar[t] ?? 0),
|
||||
to_battery: data.to_battery && (data.to_battery[t] ?? 0),
|
||||
from_battery: data.from_battery && (data.from_battery[t] ?? 0),
|
||||
from_grid: fromGrid && (fromGrid[t] ?? 0),
|
||||
to_grid: toGrid && (toGrid[t] ?? 0),
|
||||
solar: solarData && (solarData[t] ?? 0),
|
||||
to_battery: toBattery && (toBattery[t] ?? 0),
|
||||
from_battery: fromBattery && (fromBattery[t] ?? 0),
|
||||
});
|
||||
|
||||
outData.used_total[t] = used_total;
|
||||
outData.total.used_total += used_total;
|
||||
total.used_total += used_total;
|
||||
outData.grid_to_battery[t] = grid_to_battery;
|
||||
outData.total.grid_to_battery += grid_to_battery;
|
||||
total.grid_to_battery += grid_to_battery;
|
||||
outData.battery_to_grid![t] = battery_to_grid;
|
||||
outData.total.battery_to_grid += battery_to_grid;
|
||||
total.battery_to_grid += battery_to_grid;
|
||||
outData.used_battery![t] = used_battery;
|
||||
outData.total.used_battery += used_battery;
|
||||
total.used_battery += used_battery;
|
||||
outData.used_grid![t] = used_grid;
|
||||
outData.total.used_grid += used_grid;
|
||||
total.used_grid += used_grid;
|
||||
outData.used_solar![t] = used_solar;
|
||||
outData.total.used_solar += used_solar;
|
||||
total.used_solar += used_solar;
|
||||
outData.solar_to_battery[t] = solar_to_battery;
|
||||
outData.total.solar_to_battery += solar_to_battery;
|
||||
total.solar_to_battery += solar_to_battery;
|
||||
outData.solar_to_grid[t] = solar_to_grid;
|
||||
outData.total.solar_to_grid += solar_to_grid;
|
||||
total.solar_to_grid += solar_to_grid;
|
||||
});
|
||||
|
||||
return outData;
|
||||
|
||||
@@ -73,30 +73,44 @@ export const getEntities = (
|
||||
|
||||
let entityIds = Object.keys(hass.states);
|
||||
|
||||
// These run over every entity, so use Sets for O(1) membership instead of
|
||||
// repeated Array.includes scans.
|
||||
if (includeEntities) {
|
||||
const includeEntitiesSet = new Set(includeEntities);
|
||||
entityIds = entityIds.filter((entityId) =>
|
||||
includeEntities.includes(entityId)
|
||||
includeEntitiesSet.has(entityId)
|
||||
);
|
||||
}
|
||||
|
||||
if (excludeEntities) {
|
||||
const excludeEntitiesSet = new Set(excludeEntities);
|
||||
entityIds = entityIds.filter(
|
||||
(entityId) => !excludeEntities.includes(entityId)
|
||||
(entityId) => !excludeEntitiesSet.has(entityId)
|
||||
);
|
||||
}
|
||||
|
||||
if (includeDomains) {
|
||||
const includeDomainsSet = new Set(includeDomains);
|
||||
entityIds = entityIds.filter((eid) =>
|
||||
includeDomains.includes(computeDomain(eid))
|
||||
includeDomainsSet.has(computeDomain(eid))
|
||||
);
|
||||
}
|
||||
|
||||
if (excludeDomains) {
|
||||
const excludeDomainsSet = new Set(excludeDomains);
|
||||
entityIds = entityIds.filter(
|
||||
(eid) => !excludeDomains.includes(computeDomain(eid))
|
||||
(eid) => !excludeDomainsSet.has(computeDomain(eid))
|
||||
);
|
||||
}
|
||||
|
||||
// These values are the same for every entity, so compute them once instead
|
||||
// of inside the map over (potentially thousands of) entities.
|
||||
const isRTL = computeRTL(
|
||||
hass.language,
|
||||
hass.translationMetadata.translations
|
||||
);
|
||||
const domainNames = new Map<string, string>();
|
||||
|
||||
items = entityIds.map<EntityComboBoxItem>((entityId) => {
|
||||
const stateObj = hass.states[entityId];
|
||||
|
||||
@@ -110,12 +124,12 @@ export const getEntities = (
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const domainName = domainToName(hass.localize, computeDomain(entityId));
|
||||
|
||||
const isRTL = computeRTL(
|
||||
hass.language,
|
||||
hass.translationMetadata.translations
|
||||
);
|
||||
const domain = computeDomain(entityId);
|
||||
let domainName = domainNames.get(domain);
|
||||
if (domainName === undefined) {
|
||||
domainName = domainToName(hass.localize, domain);
|
||||
domainNames.set(domain, domainName);
|
||||
}
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
|
||||
@@ -7,11 +7,12 @@ interface EntitySource {
|
||||
|
||||
export type EntitySources = Record<string, EntitySource>;
|
||||
|
||||
const fetchEntitySources = (hass: HomeAssistant): Promise<EntitySources> =>
|
||||
hass.callWS({ type: "entity/source" });
|
||||
const fetchEntitySources = (
|
||||
hass: Pick<HomeAssistant, "callWS">
|
||||
): Promise<EntitySources> => hass.callWS({ type: "entity/source" });
|
||||
|
||||
export const fetchEntitySourcesWithCache = (
|
||||
hass: HomeAssistant
|
||||
hass: Pick<HomeAssistant, "callWS" | "states">
|
||||
): Promise<EntitySources> =>
|
||||
timeCachePromiseFunc(
|
||||
"_entitySources",
|
||||
|
||||
+70
-70
@@ -10,6 +10,7 @@ import { computeStateDisplayFromEntityAttributes } from "../common/entity/comput
|
||||
import { computeStateNameFromEntityAttributes } from "../common/entity/compute_state_name";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { isNumericSensorDeviceClass } from "./sensor";
|
||||
import type { FrontendLocaleData } from "./translation";
|
||||
import type { Statistics } from "./recorder";
|
||||
|
||||
@@ -164,60 +165,70 @@ export class HistoryStream {
|
||||
? (new Date().getTime() - 60 * 60 * this.hoursToShow * 1000) / 1000
|
||||
: undefined;
|
||||
const newHistory: HistoryStates = {};
|
||||
for (const entityId of Object.keys(this.combinedHistory)) {
|
||||
newHistory[entityId] = [];
|
||||
}
|
||||
for (const entityId of Object.keys(streamMessage.states)) {
|
||||
newHistory[entityId] = [];
|
||||
}
|
||||
for (const entityId of Object.keys(newHistory)) {
|
||||
if (
|
||||
entityId in this.combinedHistory &&
|
||||
entityId in streamMessage.states
|
||||
) {
|
||||
// Build the union of entity ids (existing first, then new ones) in a
|
||||
// single pass and process each entity inline. The per-entity slot is
|
||||
// always assigned below before being read, so there is no need to
|
||||
// pre-seed every key with an empty array first.
|
||||
const streamStates = streamMessage.states;
|
||||
const processEntity = (entityId: string) => {
|
||||
const inCombined = entityId in this.combinedHistory;
|
||||
const inStream = entityId in streamStates;
|
||||
if (inCombined && inStream) {
|
||||
const entityCombinedHistory = this.combinedHistory[entityId];
|
||||
const lastEntityCombinedHistory =
|
||||
entityCombinedHistory[entityCombinedHistory.length - 1];
|
||||
newHistory[entityId] = entityCombinedHistory.concat(
|
||||
streamMessage.states[entityId]
|
||||
streamStates[entityId]
|
||||
);
|
||||
if (
|
||||
streamMessage.states[entityId][0].lu < lastEntityCombinedHistory.lu
|
||||
) {
|
||||
if (streamStates[entityId][0].lu < lastEntityCombinedHistory.lu) {
|
||||
// If the history is out of order we have to sort it.
|
||||
newHistory[entityId] = newHistory[entityId].sort(
|
||||
(a, b) => a.lu - b.lu
|
||||
);
|
||||
}
|
||||
} else if (entityId in this.combinedHistory) {
|
||||
} else if (inCombined) {
|
||||
newHistory[entityId] = this.combinedHistory[entityId];
|
||||
} else {
|
||||
newHistory[entityId] = streamMessage.states[entityId];
|
||||
newHistory[entityId] = streamStates[entityId];
|
||||
return;
|
||||
}
|
||||
// Remove old history
|
||||
if (purgeBeforePythonTime && entityId in this.combinedHistory) {
|
||||
const expiredStates = newHistory[entityId].filter(
|
||||
(state) => state.lu < purgeBeforePythonTime
|
||||
);
|
||||
if (!expiredStates.length) {
|
||||
continue;
|
||||
// Remove old history (only entities present in combinedHistory reach
|
||||
// here without an early return).
|
||||
if (purgeBeforePythonTime) {
|
||||
// Single pass: split into kept (lu >= cutoff, preserving order) and
|
||||
// track the last expired state (lu < cutoff) without allocating a
|
||||
// second array.
|
||||
const states = newHistory[entityId];
|
||||
const kept: EntityHistoryState[] = [];
|
||||
let lastExpiredState: EntityHistoryState | undefined;
|
||||
for (const state of states) {
|
||||
if (state.lu < purgeBeforePythonTime) {
|
||||
lastExpiredState = state;
|
||||
} else {
|
||||
kept.push(state);
|
||||
}
|
||||
}
|
||||
newHistory[entityId] = newHistory[entityId].filter(
|
||||
(state) => state.lu >= purgeBeforePythonTime
|
||||
);
|
||||
if (
|
||||
newHistory[entityId].length &&
|
||||
newHistory[entityId][0].lu === purgeBeforePythonTime
|
||||
) {
|
||||
continue;
|
||||
if (!lastExpiredState) {
|
||||
return;
|
||||
}
|
||||
newHistory[entityId] = kept;
|
||||
if (kept.length && kept[0].lu === purgeBeforePythonTime) {
|
||||
return;
|
||||
}
|
||||
// Update the first entry to the start time state
|
||||
// as we need to preserve the start time state and
|
||||
// only expire the rest of the history as it ages.
|
||||
const lastExpiredState = expiredStates[expiredStates.length - 1];
|
||||
lastExpiredState.lu = purgeBeforePythonTime;
|
||||
delete lastExpiredState.lc;
|
||||
newHistory[entityId].unshift(lastExpiredState);
|
||||
kept.unshift(lastExpiredState);
|
||||
}
|
||||
};
|
||||
for (const entityId of Object.keys(this.combinedHistory)) {
|
||||
processEntity(entityId);
|
||||
}
|
||||
for (const entityId of Object.keys(streamStates)) {
|
||||
if (!(entityId in this.combinedHistory)) {
|
||||
processEntity(entityId);
|
||||
}
|
||||
}
|
||||
this.combinedHistory = newHistory;
|
||||
@@ -346,7 +357,6 @@ const processTimelineEntity = (
|
||||
state_localize: computeStateDisplayFromEntityAttributes(
|
||||
localize,
|
||||
locale,
|
||||
[], // numeric device classes not used for Timeline
|
||||
config,
|
||||
entities[entityId],
|
||||
entityId,
|
||||
@@ -381,16 +391,18 @@ const processLineChartEntities = (
|
||||
): LineChartUnit => {
|
||||
const data: LineChartEntity[] = [];
|
||||
|
||||
Object.keys(entities).forEach((entityId) => {
|
||||
const entityIds = Object.keys(entities);
|
||||
entityIds.forEach((entityId) => {
|
||||
const states = entities[entityId];
|
||||
const first: EntityHistoryState = states[0];
|
||||
const domain = computeDomain(entityId);
|
||||
const useLastUpdated = DOMAINS_USE_LAST_UPDATED.includes(domain);
|
||||
const processedStates: LineChartState[] = [];
|
||||
|
||||
for (const state of states) {
|
||||
let processedState: LineChartState;
|
||||
|
||||
if (DOMAINS_USE_LAST_UPDATED.includes(domain)) {
|
||||
if (useLastUpdated) {
|
||||
processedState = {
|
||||
state: state.s,
|
||||
last_changed: state.lu * 1000,
|
||||
@@ -412,13 +424,11 @@ const processLineChartEntities = (
|
||||
};
|
||||
}
|
||||
|
||||
const len = processedStates.length;
|
||||
if (
|
||||
processedStates.length > 1 &&
|
||||
equalState(
|
||||
processedState,
|
||||
processedStates[processedStates.length - 1]
|
||||
) &&
|
||||
equalState(processedState, processedStates[processedStates.length - 2])
|
||||
len > 1 &&
|
||||
equalState(processedState, processedStates[len - 1]) &&
|
||||
equalState(processedState, processedStates[len - 2])
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -444,11 +454,17 @@ const processLineChartEntities = (
|
||||
return {
|
||||
unit,
|
||||
device_class,
|
||||
identifier: Object.keys(entities).join(""),
|
||||
identifier: entityIds.join(""),
|
||||
data,
|
||||
};
|
||||
};
|
||||
|
||||
const SPECIAL_DOMAIN_CLASSES: Record<string, string | undefined> = {
|
||||
climate: "temperature",
|
||||
humidifier: "humidity",
|
||||
water_heater: "temperature",
|
||||
};
|
||||
|
||||
const NUMERICAL_DOMAINS = ["counter", "input_number", "number"];
|
||||
|
||||
const isNumericFromDomain = (domain: string) =>
|
||||
@@ -457,20 +473,12 @@ const isNumericFromDomain = (domain: string) =>
|
||||
const isNumericFromAttributes = (attributes: Record<string, any>) =>
|
||||
"unit_of_measurement" in attributes || "state_class" in attributes;
|
||||
|
||||
const isNumericSensorEntity = (
|
||||
stateObj: HassEntity,
|
||||
sensorNumericalDeviceClasses: string[]
|
||||
) =>
|
||||
stateObj.attributes.device_class != null &&
|
||||
sensorNumericalDeviceClasses.includes(stateObj.attributes.device_class);
|
||||
|
||||
const BLANK_UNIT = " ";
|
||||
|
||||
export const convertStatisticsToHistory = (
|
||||
hass: HomeAssistant,
|
||||
statistics: Statistics,
|
||||
statisticIds: string[],
|
||||
sensorNumericDeviceClasses: string[],
|
||||
splitDeviceClasses = false
|
||||
): HistoryResult => {
|
||||
// Maintain the statistic id ordering
|
||||
@@ -498,7 +506,6 @@ export const convertStatisticsToHistory = (
|
||||
statsHistoryStates,
|
||||
[],
|
||||
hass.localize,
|
||||
sensorNumericDeviceClasses,
|
||||
splitDeviceClasses,
|
||||
true
|
||||
);
|
||||
@@ -528,7 +535,6 @@ export const computeHistory = (
|
||||
stateHistory: HistoryStates,
|
||||
entityIds: string[],
|
||||
localize: LocalizeFunc,
|
||||
sensorNumericalDeviceClasses: string[],
|
||||
splitDeviceClasses = false,
|
||||
forceNumeric = false
|
||||
): HistoryResult => {
|
||||
@@ -575,7 +581,6 @@ export const computeHistory = (
|
||||
domain,
|
||||
currentState,
|
||||
numericStateFromHistory,
|
||||
sensorNumericalDeviceClasses,
|
||||
forceNumeric
|
||||
);
|
||||
|
||||
@@ -593,14 +598,8 @@ export const computeHistory = (
|
||||
}[domain];
|
||||
}
|
||||
|
||||
const specialDomainClasses = {
|
||||
climate: "temperature",
|
||||
humidifier: "humidity",
|
||||
water_heater: "temperature",
|
||||
};
|
||||
|
||||
const deviceClass: string | undefined =
|
||||
specialDomainClasses[domain] ||
|
||||
SPECIAL_DOMAIN_CLASSES[domain] ||
|
||||
(currentState?.attributes || numericStateFromHistory?.a)?.device_class;
|
||||
|
||||
const key = computeGroupKey(unit, deviceClass, splitDeviceClasses);
|
||||
@@ -656,7 +655,6 @@ export const isNumericEntity = (
|
||||
domain: string,
|
||||
currentState: HassEntity | undefined,
|
||||
numericStateFromHistory: EntityHistoryState | undefined,
|
||||
sensorNumericalDeviceClasses: string[],
|
||||
forceNumeric = false
|
||||
): boolean =>
|
||||
forceNumeric ||
|
||||
@@ -664,7 +662,7 @@ export const isNumericEntity = (
|
||||
(currentState != null && isNumericFromAttributes(currentState.attributes)) ||
|
||||
(currentState != null &&
|
||||
domain === "sensor" &&
|
||||
isNumericSensorEntity(currentState, sensorNumericalDeviceClasses)) ||
|
||||
isNumericSensorDeviceClass(currentState.attributes.device_class)) ||
|
||||
numericStateFromHistory != null;
|
||||
|
||||
export const mergeHistoryResults = (
|
||||
@@ -725,16 +723,18 @@ export const mergeHistoryResults = (
|
||||
}
|
||||
|
||||
const newLineItem: LineChartUnit = { ...historyItem, data: [] };
|
||||
const historyDataByEntity = new Map(
|
||||
historyItem.data.map((d) => [d.entity_id, d])
|
||||
);
|
||||
const ltsDataByEntity = new Map(ltsItem.data.map((d) => [d.entity_id, d]));
|
||||
const entities = new Set([
|
||||
...historyItem.data.map((d) => d.entity_id),
|
||||
...ltsItem.data.map((d) => d.entity_id),
|
||||
...historyDataByEntity.keys(),
|
||||
...ltsDataByEntity.keys(),
|
||||
]);
|
||||
|
||||
for (const entity of entities) {
|
||||
const historyDataItem = historyItem.data.find(
|
||||
(d) => d.entity_id === entity
|
||||
);
|
||||
const ltsDataItem = ltsItem.data.find((d) => d.entity_id === entity);
|
||||
const historyDataItem = historyDataByEntity.get(entity);
|
||||
const ltsDataItem = ltsDataByEntity.get(entity);
|
||||
|
||||
if (!historyDataItem || !ltsDataItem) {
|
||||
newLineItem.data.push(historyDataItem || ltsDataItem!);
|
||||
|
||||
+5
-3
@@ -4,12 +4,14 @@ import type {
|
||||
} from "home-assistant-js-websocket";
|
||||
|
||||
interface ImageEntityAttributes extends HassEntityAttributeBase {
|
||||
access_token: string;
|
||||
access_token?: string;
|
||||
}
|
||||
|
||||
export interface ImageEntity extends HassEntityBase {
|
||||
attributes: ImageEntityAttributes;
|
||||
}
|
||||
|
||||
export const computeImageUrl = (entity: ImageEntity): string =>
|
||||
`/api/image_proxy/${entity.entity_id}?token=${entity.attributes.access_token}&state=${entity.state}`;
|
||||
export const computeImageUrl = (entity: ImageEntity): string | undefined =>
|
||||
entity.attributes.access_token
|
||||
? `/api/image_proxy/${entity.entity_id}?token=${entity.attributes.access_token}&state=${entity.state}`
|
||||
: undefined;
|
||||
|
||||
@@ -43,11 +43,6 @@ export const lightSupportsColorMode = (
|
||||
mode: LightColorMode
|
||||
) => entity.attributes.supported_color_modes?.includes(mode) || false;
|
||||
|
||||
export const lightIsInColorMode = (entity: LightEntity) =>
|
||||
(entity.attributes.color_mode &&
|
||||
modesSupportingColor.includes(entity.attributes.color_mode)) ||
|
||||
false;
|
||||
|
||||
export const lightSupportsColor = (entity: LightEntity) =>
|
||||
entity.attributes.supported_color_modes?.some((mode) =>
|
||||
modesSupportingColor.includes(mode)
|
||||
@@ -159,5 +154,3 @@ export const computeDefaultFavoriteColors = (
|
||||
|
||||
return colors;
|
||||
};
|
||||
|
||||
export const formatTempColor = (value: number) => `${value} K`;
|
||||
|
||||
+2
-6
@@ -369,14 +369,10 @@ export const localizeStateMessage = (
|
||||
});
|
||||
};
|
||||
|
||||
export const filterLogbookCompatibleEntities = (
|
||||
entity,
|
||||
sensorNumericDeviceClasses: string[] = []
|
||||
) => {
|
||||
export const filterLogbookCompatibleEntities = (entity) => {
|
||||
const domain = computeStateDomain(entity);
|
||||
const continuous =
|
||||
CONTINUOUS_DOMAINS.includes(domain) ||
|
||||
(domain === "sensor" &&
|
||||
isNumericEntity(domain, entity, undefined, sensorNumericDeviceClasses));
|
||||
(domain === "sensor" && isNumericEntity(domain, entity, undefined));
|
||||
return !continuous;
|
||||
};
|
||||
|
||||
+3
-1
@@ -128,11 +128,13 @@ export const addMatterDevice = (hass: HomeAssistant) => {
|
||||
|
||||
export const commissionMatterDevice = (
|
||||
hass: HomeAssistant,
|
||||
code: string
|
||||
code: string,
|
||||
networkOnly: boolean
|
||||
): Promise<void> =>
|
||||
hass.callWS({
|
||||
type: "matter/commission",
|
||||
code,
|
||||
network_only: networkOnly,
|
||||
});
|
||||
|
||||
export const acceptSharedMatterDevice = (
|
||||
|
||||
+4
-25
@@ -1,3 +1,4 @@
|
||||
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "./sensor_numeric_device_classes";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export const SENSOR_DEVICE_CLASS_BATTERY = "battery";
|
||||
@@ -11,6 +12,9 @@ export const SENSOR_TIMESTAMP_DEVICE_CLASSES: (string | undefined)[] = [
|
||||
"uptime",
|
||||
];
|
||||
|
||||
export const isNumericSensorDeviceClass = (deviceClass?: string): boolean =>
|
||||
deviceClass != null && SENSOR_NUMERIC_DEVICE_CLASSES.includes(deviceClass);
|
||||
|
||||
export interface SensorDeviceClassUnits {
|
||||
units: string[];
|
||||
}
|
||||
@@ -23,28 +27,3 @@ export const getSensorDeviceClassConvertibleUnits = (
|
||||
type: "sensor/device_class_convertible_units",
|
||||
device_class: deviceClass,
|
||||
});
|
||||
|
||||
export interface SensorNumericDeviceClasses {
|
||||
numeric_device_classes: string[];
|
||||
}
|
||||
|
||||
let sensorNumericDeviceClassesCache:
|
||||
| Promise<SensorNumericDeviceClasses>
|
||||
| undefined;
|
||||
|
||||
export const getSensorNumericDeviceClasses = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<SensorNumericDeviceClasses> => {
|
||||
if (sensorNumericDeviceClassesCache) {
|
||||
return sensorNumericDeviceClassesCache;
|
||||
}
|
||||
sensorNumericDeviceClassesCache = hass
|
||||
.callWS<SensorNumericDeviceClasses>({
|
||||
type: "sensor/numeric_device_classes",
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
sensorNumericDeviceClassesCache = undefined;
|
||||
throw err;
|
||||
});
|
||||
return sensorNumericDeviceClassesCache!;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// This file is auto-generated from Home Assistant Core's `SensorDeviceClass`
|
||||
// (all values minus `NON_NUMERIC_DEVICE_CLASSES`). Do not edit by hand.
|
||||
// Regenerate with `script/gen_numeric_device_classes`.
|
||||
|
||||
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
|
||||
"absolute_humidity",
|
||||
"apparent_power",
|
||||
"aqi",
|
||||
"area",
|
||||
"atmospheric_pressure",
|
||||
"battery",
|
||||
"blood_glucose_concentration",
|
||||
"carbon_dioxide",
|
||||
"carbon_monoxide",
|
||||
"conductivity",
|
||||
"current",
|
||||
"data_rate",
|
||||
"data_size",
|
||||
"distance",
|
||||
"duration",
|
||||
"energy",
|
||||
"energy_distance",
|
||||
"energy_storage",
|
||||
"frequency",
|
||||
"gas",
|
||||
"humidity",
|
||||
"illuminance",
|
||||
"irradiance",
|
||||
"moisture",
|
||||
"monetary",
|
||||
"nitrogen_dioxide",
|
||||
"nitrogen_monoxide",
|
||||
"nitrous_oxide",
|
||||
"ozone",
|
||||
"ph",
|
||||
"pm1",
|
||||
"pm10",
|
||||
"pm25",
|
||||
"pm4",
|
||||
"power",
|
||||
"power_factor",
|
||||
"precipitation",
|
||||
"precipitation_intensity",
|
||||
"pressure",
|
||||
"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",
|
||||
];
|
||||
@@ -6,7 +6,9 @@ export interface SupervisorUpdateConfig {
|
||||
core_backup_before_update: boolean;
|
||||
}
|
||||
|
||||
export const getSupervisorUpdateConfig = async (hass: HomeAssistant) =>
|
||||
export const getSupervisorUpdateConfig = async (
|
||||
hass: Pick<HomeAssistant, "callWS">
|
||||
) =>
|
||||
hass.callWS<SupervisorUpdateConfig>({
|
||||
type: "hassio/update/config/info",
|
||||
});
|
||||
|
||||
+36
-22
@@ -77,7 +77,10 @@ export const updateButtonIsDisabled = (entity: UpdateEntity): boolean =>
|
||||
export const updateIsInstalling = (entity: UpdateEntity): boolean =>
|
||||
!!entity.attributes.in_progress;
|
||||
|
||||
export const updateReleaseNotes = (hass: HomeAssistant, entityId: string) =>
|
||||
export const updateReleaseNotes = (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
entityId: string
|
||||
) =>
|
||||
hass.callWS<string | null>({
|
||||
type: "update/release_notes",
|
||||
entity_id: entityId,
|
||||
@@ -146,10 +149,20 @@ export const filterUpdateEntitiesParameterized = (
|
||||
return updateCanInstall(entity, showSkipped);
|
||||
});
|
||||
|
||||
export const installUpdates = (hass: HomeAssistant, entityIds: string[]) =>
|
||||
hass.callService("update", "install", {
|
||||
entity_id: entityIds,
|
||||
});
|
||||
export const installUpdates = (
|
||||
hass: HomeAssistant,
|
||||
entityIds: string[],
|
||||
notifyOnError = true
|
||||
) =>
|
||||
hass.callService(
|
||||
"update",
|
||||
"install",
|
||||
{
|
||||
entity_id: entityIds,
|
||||
},
|
||||
undefined,
|
||||
notifyOnError
|
||||
);
|
||||
|
||||
export const checkForEntityUpdates = async (
|
||||
element: HTMLElement,
|
||||
@@ -221,6 +234,24 @@ export const computeUpdateStateDisplay = (
|
||||
const state = stateObj.state;
|
||||
const attributes = stateObj.attributes;
|
||||
|
||||
// An install can be in progress even when the state is "off", e.g. when
|
||||
// downgrading firmware (installed_version is newer than latest_version).
|
||||
// Show the installing status regardless of state in that case.
|
||||
if (updateIsInstalling(stateObj)) {
|
||||
const supportsProgress =
|
||||
supportsFeature(stateObj, UpdateEntityFeature.PROGRESS) &&
|
||||
attributes.update_percentage !== null;
|
||||
if (supportsProgress) {
|
||||
return hass.localize("ui.card.update.installing_with_progress", {
|
||||
progress: formatNumber(attributes.update_percentage!, hass.locale, {
|
||||
maximumFractionDigits: attributes.display_precision,
|
||||
minimumFractionDigits: attributes.display_precision,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return hass.localize("ui.card.update.installing");
|
||||
}
|
||||
|
||||
if (state === "off") {
|
||||
const isSkipped =
|
||||
attributes.latest_version &&
|
||||
@@ -231,23 +262,6 @@ export const computeUpdateStateDisplay = (
|
||||
return hass.formatEntityState(stateObj);
|
||||
}
|
||||
|
||||
if (state === "on") {
|
||||
if (updateIsInstalling(stateObj)) {
|
||||
const supportsProgress =
|
||||
supportsFeature(stateObj, UpdateEntityFeature.PROGRESS) &&
|
||||
attributes.update_percentage !== null;
|
||||
if (supportsProgress) {
|
||||
return hass.localize("ui.card.update.installing_with_progress", {
|
||||
progress: formatNumber(attributes.update_percentage!, hass.locale, {
|
||||
maximumFractionDigits: attributes.display_precision,
|
||||
minimumFractionDigits: attributes.display_precision,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return hass.localize("ui.card.update.installing");
|
||||
}
|
||||
}
|
||||
|
||||
return hass.formatEntityState(stateObj);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,13 +10,21 @@ import "../../components/ha-button";
|
||||
import type { HaSwitch } from "../../components/ha-switch";
|
||||
import type { ConfigEntryMutableParams } from "../../data/config_entries";
|
||||
import { updateConfigEntry } from "../../data/config_entries";
|
||||
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { showAlertDialog } from "../generic/show-dialog-box";
|
||||
import type { ConfigEntrySystemOptionsDialogParams } from "./show-dialog-config-entry-system-options";
|
||||
|
||||
interface SystemOptionsState {
|
||||
disableNewEntities: boolean;
|
||||
disablePolling: boolean;
|
||||
}
|
||||
|
||||
@customElement("dialog-config-entry-system-options")
|
||||
class DialogConfigEntrySystemOptions extends LitElement {
|
||||
class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptionsState>()(
|
||||
LitElement
|
||||
) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _disableNewEntities!: boolean;
|
||||
@@ -38,6 +46,13 @@ class DialogConfigEntrySystemOptions extends LitElement {
|
||||
this._error = undefined;
|
||||
this._disableNewEntities = params.entry.pref_disable_new_entities;
|
||||
this._disablePolling = params.entry.pref_disable_polling;
|
||||
this._initDirtyTracking(
|
||||
{ type: "shallow" },
|
||||
{
|
||||
disableNewEntities: this._disableNewEntities,
|
||||
disablePolling: this._disablePolling,
|
||||
}
|
||||
);
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
@@ -68,7 +83,7 @@ class DialogConfigEntrySystemOptions extends LitElement {
|
||||
) || this._params.entry.domain,
|
||||
}
|
||||
)}
|
||||
prevent-scrim-close
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
${this._error ? html` <div class="error">${this._error}</div> ` : ""}
|
||||
@@ -135,7 +150,7 @@ class DialogConfigEntrySystemOptions extends LitElement {
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._updateEntry}
|
||||
.disabled=${this._submitting}
|
||||
.disabled=${this._submitting || !this.isDirtyState}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.config_entry_system_options.update"
|
||||
@@ -149,11 +164,19 @@ class DialogConfigEntrySystemOptions extends LitElement {
|
||||
private _disableNewEntitiesChanged(ev: Event): void {
|
||||
this._error = undefined;
|
||||
this._disableNewEntities = !(ev.target as HaSwitch).checked;
|
||||
this._updateDirtyState({
|
||||
disableNewEntities: this._disableNewEntities,
|
||||
disablePolling: this._disablePolling,
|
||||
});
|
||||
}
|
||||
|
||||
private _disablePollingChanged(ev: Event): void {
|
||||
this._error = undefined;
|
||||
this._disablePolling = !(ev.target as HaSwitch).checked;
|
||||
this._updateDirtyState({
|
||||
disableNewEntities: this._disableNewEntities,
|
||||
disablePolling: this._disablePolling,
|
||||
});
|
||||
}
|
||||
|
||||
private async _updateEntry(): Promise<void> {
|
||||
|
||||
@@ -403,6 +403,7 @@ class DataEntryFlowDialog extends LitElement {
|
||||
.flowConfig=${this._params.flowConfig}
|
||||
.step=${this._step}
|
||||
.hass=${this.hass}
|
||||
.domain=${this._params.domain ?? this._step.handler}
|
||||
@flow-step-footer-state-changed=${this
|
||||
._handleFooterStateChanged}
|
||||
></step-flow-form>
|
||||
|
||||
@@ -106,7 +106,9 @@ class EntityPreviewRow extends LitElement {
|
||||
}
|
||||
`;
|
||||
|
||||
private _renderEntityState(stateObj: HassEntity): TemplateResult | string {
|
||||
private _renderEntityState(
|
||||
stateObj: HassEntity
|
||||
): TemplateResult | string | typeof nothing {
|
||||
const domain = stateObj.entity_id.split(".", 1)[0];
|
||||
const disabled = stateObj.state === UNAVAILABLE;
|
||||
const noValue =
|
||||
@@ -216,7 +218,10 @@ class EntityPreviewRow extends LitElement {
|
||||
}
|
||||
|
||||
if (domain === "image") {
|
||||
const image: string = computeImageUrl(stateObj as ImageEntity);
|
||||
const image = computeImageUrl(stateObj as ImageEntity);
|
||||
if (!image) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<img
|
||||
alt=${ifDefined(stateObj?.attributes.friendly_name)}
|
||||
|
||||
@@ -35,6 +35,10 @@ class StepFlowForm extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
// The integration domain this flow belongs to. Unlike `step.handler`, this is
|
||||
// the domain even for options flows (where the handler is the config entry id).
|
||||
@property({ attribute: false }) public domain?: string;
|
||||
|
||||
@state() private _loading = false;
|
||||
|
||||
@state() private _stepData?: Record<string, any>;
|
||||
@@ -108,7 +112,7 @@ class StepFlowForm extends LitElement {
|
||||
.computeHelper=${this._helperCallback}
|
||||
.computeError=${this._errorCallback}
|
||||
.localizeValue=${this._localizeValueCallback}
|
||||
.context=${{ handler: step.handler }}
|
||||
.context=${{ handler: step.handler, domain: this.domain }}
|
||||
></ha-form>`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import "../../components/ha-button";
|
||||
import "../../components/ha-form/ha-form";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/ha-dialog";
|
||||
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassDialog, ShowDialogParams } from "../make-dialog-manager";
|
||||
@@ -20,7 +21,7 @@ interface StackEntry {
|
||||
|
||||
@customElement("dialog-form")
|
||||
export class DialogForm
|
||||
extends LitElement
|
||||
extends DirtyStateProviderMixin<FormDialogData>()(LitElement)
|
||||
implements HassDialog<FormDialogData>
|
||||
{
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
@@ -39,6 +40,7 @@ export class DialogForm
|
||||
this._params = params;
|
||||
this._data = params.data || {};
|
||||
this._open = true;
|
||||
this._initDirtyTracking({ type: "deep" }, this._data);
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
@@ -62,6 +64,7 @@ export class DialogForm
|
||||
const nested = ev.detail.dialogParams as FormDialogParams;
|
||||
this._params = nested;
|
||||
this._data = nested?.data || {};
|
||||
this._initDirtyTracking({ type: "deep" }, this._data);
|
||||
};
|
||||
|
||||
private _popStack(): string | undefined {
|
||||
@@ -72,6 +75,7 @@ export class DialogForm
|
||||
this._stack = this._stack.slice(0, -1);
|
||||
this._params = prev.params;
|
||||
this._data = prev.data;
|
||||
this._initDirtyTracking({ type: "deep" }, this._data);
|
||||
return prev.nestedField;
|
||||
}
|
||||
|
||||
@@ -115,6 +119,7 @@ export class DialogForm
|
||||
: data;
|
||||
|
||||
this._data = deepClone({ ...this._data, [nestedField]: newValue });
|
||||
this._updateDirtyState(this._data);
|
||||
}
|
||||
|
||||
private _cancel(): void {
|
||||
@@ -131,6 +136,7 @@ export class DialogForm
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
this._data = ev.detail.value;
|
||||
this._updateDirtyState(this._data);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -142,7 +148,7 @@ export class DialogForm
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
header-title=${this._params.title}
|
||||
prevent-scrim-close
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<ha-form
|
||||
|
||||
@@ -13,11 +13,18 @@ import "../../components/ha-textarea";
|
||||
import type { HaTextArea } from "../../components/ha-textarea";
|
||||
import "../../components/input/ha-input";
|
||||
import type { HaInput } from "../../components/input/ha-input";
|
||||
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { DialogBoxParams } from "./show-dialog-box";
|
||||
|
||||
interface DialogBoxDirtyState {
|
||||
value: string;
|
||||
}
|
||||
|
||||
@customElement("dialog-box")
|
||||
class DialogBox extends LitElement {
|
||||
class DialogBox extends DirtyStateProviderMixin<DialogBoxDirtyState>()(
|
||||
LitElement
|
||||
) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: DialogBoxParams;
|
||||
@@ -43,6 +50,10 @@ class DialogBox extends LitElement {
|
||||
this._params = params;
|
||||
this._validInput = true;
|
||||
this._open = true;
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{ value: params.defaultValue ?? "" }
|
||||
);
|
||||
await this.updateComplete;
|
||||
this._validateInput();
|
||||
}
|
||||
@@ -77,7 +88,7 @@ class DialogBox extends LitElement {
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
type=${confirmPrompt ? "alert" : "standard"}
|
||||
?prevent-scrim-close=${confirmPrompt}
|
||||
.preventScrimClose=${!!this._params.confirmation || this.isDirtyState}
|
||||
@closed=${this._dialogClosed}
|
||||
aria-labelledby="dialog-box-title"
|
||||
aria-describedby="dialog-box-description"
|
||||
@@ -212,6 +223,7 @@ class DialogBox extends LitElement {
|
||||
if (this._params!.confirm) {
|
||||
this._params!.confirm(this._textField?.value);
|
||||
}
|
||||
this._markDirtyStateClean();
|
||||
this._closeDialog();
|
||||
}
|
||||
|
||||
@@ -219,6 +231,9 @@ class DialogBox extends LitElement {
|
||||
this._validInput = this._params?.prompt
|
||||
? (this._textField?.checkValidity() ?? true)
|
||||
: true;
|
||||
if (this._params?.prompt) {
|
||||
this._updateDirtyState({ value: this._textField?.value ?? "" });
|
||||
}
|
||||
}
|
||||
|
||||
private _closeDialog() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user