Compare commits

..

4 Commits

Author SHA1 Message Date
Stefan Agner 78d4072049 Explain isolated network behavior on the app network card
Add an info alert to the isolated network access section covering what
users should know about the implementation: the app appears as a
separate device, IPv6 is automatic via SLAAC, the host and the app
cannot reach each other on the isolated network by design (Supervisor
communication like ingress is unaffected), and apps that introspect
host interfaces may behave differently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:06:14 +02:00
Stefan Agner f2827d0ab6 Show the endpoint MAC address on the app network card
Display the saved endpoint MAC below the IP address field of the
isolated network access section, so users can set up router or
firewall rules right where they configure the endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:55:02 +02:00
Stefan Agner 22b83b20fc Show the MAC address of isolated app network endpoints
The Supervisor now reports network_isolation_mac, a stable MAC derived
from the static IP that is known as soon as isolation is configured.
Show it next to the IP on the app info tab, including while the app is
stopped, so users can create router or firewall rules before the first
start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:06:46 +02:00
Stefan Agner 65f7d65462 Add isolated network access settings for apps
Apps that use host networking can now be switched to an isolated
network endpoint (macvlan) from the network card on the Configuration
tab, when the Supervisor reports network_isolation_available. The card
gains a toggle, an interface select populated from the
network_isolation_capable host interfaces, and an IPv4 address field.
Saving posts the network_isolation option and suggests an app restart,
same as port changes. The info tab shows the assigned IP while the app
is running.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:04:24 +02:00
611 changed files with 9153 additions and 77415 deletions
-1
View File
@@ -289,7 +289,6 @@ 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
+2 -2
View File
@@ -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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: master
persist-credentials: false
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
+4 -4
View File
@@ -27,7 +27,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
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@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
# ️ 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@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
+2 -2
View File
@@ -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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: master
persist-credentials: false
+1 -1
View File
@@ -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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
+1 -1
View File
@@ -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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
contents: write
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -1,190 +0,0 @@
name: Pull request standards
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, no PR code checkout
types:
- opened
- edited
- reopened
- ready_for_review
branches:
- dev
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
check:
name: Check pull request follows contribution standards
runs-on: ubuntu-latest
permissions:
pull-requests: write # To label and comment on pull requests
steps:
- name: Check pull request standards
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const pr = context.payload.pull_request;
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
if (pr.user.type === "Bot") {
core.info(`Skipping bot author: ${pr.user.login}`);
return;
}
if (pr.draft) {
core.info("Skipping draft pull request");
return;
}
try {
await github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: pr.user.login,
});
core.info(`Skipping organization member: ${pr.user.login}`);
return;
} catch (error) {
core.info(`${pr.user.login} is not an organization member, checking standards`);
}
const label = "Needs Template";
const marker = "<!-- pr-standards-check -->";
const { owner, repo } = context.repo;
const issue_number = pr.number;
const body = (pr.body || "").replace(/<!--[\s\S]*?-->/g, "");
const normalized = body.toLowerCase();
// Ignore 404s from mutations that race manual edits or cancelled runs.
const ignoreMissing = async (fn) => {
try {
await fn();
} catch (error) {
if (error.status === 404) {
core.info("Target already removed, nothing to do");
return;
}
throw error;
}
};
// Hide/restore our comment via GraphQL (REST cannot minimize).
const setMinimized = async (subjectId, minimized) => {
const mutation = minimized
? `mutation($id: ID!) {
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
clientMutationId
}
}`
: `mutation($id: ID!) {
unminimizeComment(input: { subjectId: $id }) {
clientMutationId
}
}`;
try {
await github.graphql(mutation, { id: subjectId });
} catch (error) {
core.info(
`Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}`
);
}
};
// Content of a "## <name>" section, or null when the heading is absent.
const section = (name) => {
const match = body.match(
new RegExp(`##\\s${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
);
return match ? match[1] : null;
};
const problems = [];
const requiredHeadings = [
"## proposed change",
"## type of change",
"## checklist",
];
if (requiredHeadings.some((h) => !normalized.includes(h))) {
problems.push(
"Use the pull request template without removing its sections."
);
}
const typeOfChange = section("type of change");
if (typeOfChange !== null) {
const ticked = (typeOfChange.match(/-\s*\[[xX]\]/g) || []).length;
if (ticked !== 1) {
problems.push(
'Select exactly one option under "Type of change".'
);
}
}
const proposedChange = section("proposed change");
if (proposedChange !== null && proposedChange.trim().length === 0) {
problems.push('Describe your changes under "Proposed change".');
}
const isValid = problems.length === 0;
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number, per_page: 100 }
);
const existing = comments.find((c) => c.body.includes(marker));
const hasLabel = pr.labels.some((l) => l.name === label);
if (isValid) {
core.info("Pull request standards met");
if (hasLabel) {
await ignoreMissing(() =>
github.rest.issues.removeLabel({
owner, repo, issue_number, name: label,
})
);
}
if (existing) {
await setMinimized(existing.node_id, true);
}
return;
}
core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
if (!hasLabel) {
await github.rest.issues.addLabels({
owner, repo, issue_number, labels: [label],
});
}
const message =
`${marker}\n` +
`Hey @${pr.user.login}!\n\n` +
`Thank you for your contribution! To help reviewers, please update ` +
`this pull request to follow our pull request standards:\n\n` +
problems.map((p) => `- ${p}`).join("\n") +
`\n\n` +
`Please complete the ` +
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
`for more on creating a great pull request (see point 6).`;
if (existing) {
await github.rest.issues.updateComment({
owner, repo, comment_id: existing.id, body: message,
});
await setMinimized(existing.node_id, false);
} else {
await github.rest.issues.createComment({
owner, repo, issue_number, body: message,
});
}
// Fail this check so it can block the PR from being merged
core.setFailed(
`Pull request standards not met:\n- ${problems.join("\n- ")}`
);
+4 -4
View File
@@ -26,7 +26,7 @@ jobs:
if: github.repository_owner == 'home-assistant'
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@e91ad1948e57189485b9c1ad608af0c303946f89 # master
uses: home-assistant/actions/helpers/verify-version@868e6cb4607727d764341a158d98872cd63fa658 # 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@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
@@ -1,51 +0,0 @@
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`.
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
-1
View File
@@ -58,4 +58,3 @@ test/coverage/
.claude
.cursor
.opencode
test/benchmarks/results/
+1 -1
View File
@@ -1 +1 @@
24.17.0
24.16.0
@@ -1,18 +0,0 @@
diff --git a/lib/cook-raw-quasi.js b/lib/cook-raw-quasi.js
index 3ea8fa7be8e357c1066d7417caeeecd841415208..6bf04ab0bed8897b5ff2898ca835867aec5cee6a 100644
--- a/lib/cook-raw-quasi.js
+++ b/lib/cook-raw-quasi.js
@@ -1,10 +1,11 @@
'use strict';
-function cookRawQuasi({transform}, raw) {
+function cookRawQuasi({transformSync}, raw) {
// This nasty hack is needed until https://github.com/babel/babel/issues/9242 is resolved.
const args = {raw};
- transform('cooked`' + args.raw + '`', {
+ // Babel 8 removed synchronous `transform`; use `transformSync` instead.
+ transformSync('cooked`' + args.raw + '`', {
babelrc: false,
configFile: false,
plugins: [
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,4 +13,4 @@ nodeLinker: node-modules
npmMinimalAgeGate: 3d
yarnPath: .yarn/releases/yarn-4.17.0.cjs
yarnPath: .yarn/releases/yarn-4.16.0.cjs
+1
View File
@@ -104,6 +104,7 @@ module.exports.babelOptions = ({
{
useBuiltIns: "usage",
corejs: dependencies["core-js"],
bugfixes: true,
shippedProposals: true,
},
],
+1 -18
View File
@@ -103,29 +103,12 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
if (!toProcess) {
console.error("Unknown category", group.category);
if (!group.subsections && !group.pages) {
if (!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) {
@@ -1,40 +0,0 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process";
import gulp from "gulp";
import paths from "../paths.cjs";
const SOURCE_URL =
process.env.SENSOR_METADATA_URL ||
"https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/generated/sensor.json";
const TARGET = join(
paths.root_dir,
"src",
"data",
"sensor_numeric_device_classes.ts"
);
gulp.task("gen-numeric-device-classes", async () => {
const response = await fetch(SOURCE_URL);
if (!response.ok) {
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status}`);
}
const data = await response.json();
const classes = [...(data.numeric_device_classes ?? [])].sort();
if (!classes.length) {
throw new Error(`No numeric_device_classes found in ${SOURCE_URL}`);
}
const content = `// This file is auto-generated from Home Assistant Core's \`SensorDeviceClass\`
// (all values minus \`NON_NUMERIC_DEVICE_CLASSES\`). Do not edit by hand.
// Regenerate with \`script/gen_numeric_device_classes\`.
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
${classes.map((deviceClass) => ` "${deviceClass}",`).join("\n")}
];
`;
await writeFile(TARGET, content);
});
-1
View File
@@ -9,7 +9,6 @@ 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";
+3 -38
View File
@@ -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 { demoDevices } from "./stubs/devices";
import { mockConfigEntries } from "./stubs/config_entries";
import { mockDeviceRegistry } from "./stubs/device_registry";
import { mockEnergy } from "./stubs/energy";
import { energyEntities } from "./stubs/entities";
@@ -16,7 +16,6 @@ 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";
@@ -30,31 +29,6 @@ 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() {
@@ -87,18 +61,9 @@ export class HaDemo extends HomeAssistantAppEl {
mockIcons(hass);
mockEnergy(hass);
mockPersistentNotification(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))
);
mockConfigEntries(hass);
mockAreaRegistry(hass);
mockDeviceRegistry(hass, demoDevices);
mockDeviceRegistry(hass);
mockFloorRegistry(hass);
mockLabelRegistry(hass);
mockEntityRegistry(hass, [
-19
View File
@@ -1,19 +0,0 @@
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
View File
@@ -3,7 +3,4 @@ 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,
}));
};
-69
View File
@@ -1,69 +0,0 @@
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;
}
);
};
-83
View File
@@ -1,83 +0,0 @@
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;
}
);
};
-45
View File
@@ -1,45 +0,0 @@
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
);
};
-118
View File
@@ -1,118 +0,0 @@
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");
};
-40
View File
@@ -1,40 +0,0 @@
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);
};
+20 -120
View File
@@ -1,126 +1,26 @@
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 { getConfigEntries } from "../../../src/data/config_entries";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
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",
export const mockConfigEntries = (hass: MockHomeAssistant) => {
hass.mockWS<typeof getConfigEntries>("config_entries/get", () => [
{
entry_id: "mock-entry-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;
}
);
]);
};
-18
View File
@@ -1,18 +0,0 @@
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: [],
}));
};
-53
View File
@@ -1,53 +0,0 @@
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 -17
View File
@@ -1,7 +1,4 @@
import type {
EntityRegistryEntry,
ExtEntityRegistryEntry,
} from "../../../src/data/entity/entity_registry";
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockEntityRegistry = (
@@ -9,17 +6,4 @@ 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;
}
);
};
-12
View File
@@ -1,12 +0,0 @@
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" },
})
);
};
-39
View File
@@ -1,39 +0,0 @@
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);
};
-1
View File
@@ -42,7 +42,6 @@ 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);
};
-72
View File
@@ -1,72 +0,0 @@
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)
);
};
-13
View File
@@ -1,13 +0,0 @@
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",
})
);
};
-20
View File
@@ -1,20 +0,0 @@
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[] }));
};
-18
View File
@@ -1,18 +0,0 @@
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) : {};
});
};
-7
View File
@@ -1,7 +0,0 @@
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 => ({}));
};
-37
View File
@@ -1,37 +0,0 @@
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;
}
);
};
-28
View File
@@ -1,33 +1,5 @@
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);
};
-27
View File
@@ -1,27 +0,0 @@
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);
};
-11
View File
@@ -62,17 +62,6 @@ 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.
+9 -164
View File
@@ -10,10 +10,6 @@ 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
@@ -31,162 +27,31 @@ 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",
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",
],
},
],
// 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"],
},
{
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",
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"],
},
pages: [
"editor-trigger",
"editor-condition",
"editor-action",
"trace",
"trace-timeline",
],
},
{
@@ -199,26 +64,6 @@ 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",
@@ -1,75 +0,0 @@
import type { DemoTrace } from "./types";
export const notTriggeredTrace: DemoTrace = {
trace: {
last_step: "trigger/0",
run_id: "788767ce152d3d4475134bf1107986d4",
state: "stopped",
script_execution: "not_triggered",
not_triggered: true,
timestamp: {
start: "2021-03-25T04:36:51.223337+00:00",
finish: "2021-03-25T04:36:51.223341+00:00",
},
// Not-triggered traces have no trigger description.
trigger: null,
domain: "automation",
item_id: "1781703842452",
trace: {
"trigger/0": [
{
path: "trigger/0",
timestamp: "2021-03-25T04:36:51.223340+00:00",
changed_variables: {
trigger: {
id: "0",
idx: "0",
alias: null,
platform: "light.turned_on",
},
},
result: {
reason: "new_state_not_a_match",
data: {
entity_id: "light.bed_light",
to_state: "off",
},
},
},
],
},
config: {
id: "1781703842452",
alias: "Light Turned On Notification",
description: "Send a notification when a specific light is turned on.",
triggers: [
{
trigger: "light.turned_on",
target: {
floor_id: "test",
},
options: {
for: "00:00:00",
behavior: "each",
},
},
],
conditions: [],
actions: [
{
action: "notify.notify",
data: {
message: "A light was turned on.",
},
},
],
mode: "single",
},
context: {
id: "01KVAX7CG7XBDYGJYAGA4XJHGX",
parent_id: "01KVAX7CG631JRX4H3JS5JJ11Q",
user_id: null,
},
},
logbookEntries: [],
};
+20 -60
View File
@@ -40,26 +40,15 @@ interface GalleryPage {
demo?: unknown;
}
interface GallerySidebarSubsection {
header: string;
pages: string[];
}
interface GallerySidebarGroup {
category: string;
header?: string;
icon?: string;
pages?: string[];
subsections?: GallerySidebarSubsection[];
pages: string[];
}
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}/${groupPages(GALLERY_SIDEBAR[0])[0]}`;
const DEFAULT_PAGE = `${GALLERY_SIDEBAR[0].category}/${GALLERY_SIDEBAR[0].pages[0]}`;
const mql = matchMedia("(prefers-color-scheme: dark)");
@@ -295,15 +284,26 @@ class HaGallery extends LitElement {
const sidebar: unknown[] = [];
for (const group of GALLERY_SIDEBAR) {
const expanded = groupPages(group).some(
const links: unknown[] = [];
const expanded = group.pages.some(
(page) => this._page === `${group.category}/${page}`
);
const content = group.subsections
? group.subsections.map((subsection) =>
this._renderSidebarSubsection(group, subsection)
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
)
: this._renderPageLinks(group, group.pages ?? []);
);
}
sidebar.push(
group.header
@@ -321,46 +321,16 @@ class HaGallery extends LitElement {
.path=${group.icon}
></ha-svg-icon>`
: nothing}
${content}
${links}
</ha-expansion-panel>
`
: content
: links
);
}
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,
@@ -615,16 +585,6 @@ 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);
@@ -24,33 +24,6 @@ const traces: DemoTrace[] = [
error: 'Variable "beer" cannot be None',
}),
mockDemoTrace({ state: "stopped", script_execution: "cancelled" }),
mockDemoTrace({
state: "stopped",
script_execution: "not_triggered",
not_triggered: true,
// Not-triggered traces have no trigger description.
trigger: null,
trace: {
"trigger/0": [
{
path: "trigger/0",
changed_variables: {
trigger: {
id: "0",
idx: "0",
alias: null,
platform: "light.turned_on",
},
},
result: {
reason: "new_state_not_a_match",
data: { entity_id: "light.bed_light", to_state: "off" },
},
timestamp: "2021-03-25T04:36:51.223693+00:00",
},
],
},
}),
];
@customElement("demo-automation-trace-timeline")
+8 -28
View File
@@ -2,20 +2,17 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, queryAll, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/components/trace/ha-trace-path-details";
import type { HatScriptGraph } from "../../../../src/components/trace/hat-script-graph";
import "../../../../src/components/trace/hat-script-graph";
import "../../../../src/components/trace/hat-trace-timeline";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
import { basicTrace } from "../../data/traces/basic_trace";
import { motionLightTrace } from "../../data/traces/motion-light-trace";
import { notTriggeredTrace } from "../../data/traces/not-triggered-trace";
import type { DemoTrace } from "../../data/traces/types";
const traces: DemoTrace[] = [basicTrace, motionLightTrace, notTriggeredTrace];
const traces: DemoTrace[] = [basicTrace, motionLightTrace];
@customElement("demo-automation-trace")
export class DemoAutomationTrace extends LitElement {
@@ -23,25 +20,18 @@ export class DemoAutomationTrace extends LitElement {
@state() private _selected = {};
@queryAll("hat-script-graph") private _graphs!: NodeListOf<HatScriptGraph>;
protected render() {
if (!this.hass) {
return nothing;
}
return html`
${traces.map((trace, idx) => {
const graph = this._graphs?.[idx];
const selectedPath = this._selected[idx];
const selectedNode = selectedPath
? graph?.renderedNodes[selectedPath]
: undefined;
return html`
${traces.map(
(trace, idx) => html`
<ha-card .header=${trace.trace.config.alias}>
<div class="card-content">
<hat-script-graph
.trace=${trace.trace}
.selected=${selectedPath}
.selected=${this._selected[idx]}
@graph-node-selected=${this._handleGraphNodeSelected}
.sampleIdx=${idx}
></hat-script-graph>
@@ -50,25 +40,15 @@ export class DemoAutomationTrace extends LitElement {
.hass=${this.hass}
.trace=${trace.trace}
.logbookEntries=${trace.logbookEntries}
.selectedPath=${selectedPath}
.selectedPath=${this._selected[idx]}
@value-changed=${this._handleTimelineValueChanged}
.sampleIdx=${idx}
></hat-trace-timeline>
${selectedNode && graph
? html`<ha-trace-path-details
.hass=${this.hass}
.trace=${trace.trace}
.selected=${selectedNode}
.logbookEntries=${trace.logbookEntries}
.trackedNodes=${graph.trackedNodes}
.renderedNodes=${graph.renderedNodes}
></ha-trace-path-details>`
: nothing}
<button @click=${() => console.log(trace)}>Log trace</button>
</div>
</ha-card>
`;
})}
`
)}
`;
}
+1 -32
View File
@@ -1,5 +1,4 @@
import { ContextProvider } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
@@ -15,11 +14,6 @@ import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { BlueprintInput } from "../../../../src/data/blueprint";
import {
configContext,
internationalizationContext,
} from "../../../../src/data/context";
import { updateHassGroups } from "../../../../src/data/context/updateContext";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
@@ -502,10 +496,6 @@ const SCHEMAS: {
},
},
},
password: {
label: "Password",
selector: { text: { type: "password" } },
},
},
},
},
@@ -528,17 +518,6 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
private data = SCHEMAS.map(() => ({}));
// The date/datetime selectors and the date-picker dialog consume these
// contexts (provided by the root element in the real app). Provide them here
// so they work in the gallery.
private _i18nProvider = new ContextProvider(this, {
context: internationalizationContext,
});
private _configProvider = new ContextProvider(this, {
context: configContext,
});
constructor() {
super();
const hass = provideHass(this);
@@ -560,16 +539,6 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
el.hass = this.hass;
}
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("hass") && this.hass) {
this._i18nProvider.setValue(
updateHassGroups.internationalization(this.hass)
);
this._configProvider.setValue(updateHassGroups.config(this.hass));
}
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("show-dialog", this._dialogManager);
+7 -16
View File
@@ -4,30 +4,21 @@ title: Home
# Welcome to Home Assistant Design
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.
This portal aims to aid designers and developers on improving the Home Assistant interface. It consists of working code, resources and guidelines.
## Browse the gallery
## Home Assistant interface
- [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.
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.
## Testing and playground
### Material Design
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.
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>.
## 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. Theres a lot a designer can contribute to:
- 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.
- 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.
- 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!
+2
View File
@@ -353,6 +353,7 @@ export class DemoEntityState extends LitElement {
title: "Icon",
template: (entry) => html`
<state-badge
.hass=${hass}
.stateObj=${entry.stateObj}
.stateColor=${true}
></state-badge>
@@ -371,6 +372,7 @@ export class DemoEntityState extends LitElement {
hass.localize,
entry.stateObj,
hass.locale,
[], // numericDeviceClasses
hass.config,
hass.entities
)}`,
+29 -31
View File
@@ -21,14 +21,13 @@
"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)",
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "8.0.0",
"@babel/runtime": "7.29.7",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.10.3",
@@ -36,26 +35,26 @@
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.1",
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.1",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.9",
"@formatjs/intl-datetimeformat": "7.4.8",
"@formatjs/intl-displaynames": "7.3.10",
"@formatjs/intl-durationformat": "0.10.15",
"@formatjs/intl-durationformat": "0.10.14",
"@formatjs/intl-getcanonicallocales": "3.2.10",
"@formatjs/intl-listformat": "8.3.10",
"@formatjs/intl-locale": "5.3.9",
"@formatjs/intl-numberformat": "9.3.11",
"@formatjs/intl-pluralrules": "6.3.10",
"@formatjs/intl-relativetimeformat": "12.3.10",
"@fullcalendar/core": "6.1.21",
"@fullcalendar/daygrid": "6.1.21",
"@fullcalendar/interaction": "6.1.21",
"@fullcalendar/list": "6.1.21",
"@fullcalendar/luxon3": "6.1.21",
"@fullcalendar/timegrid": "6.1.21",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
"@fullcalendar/list": "6.1.20",
"@fullcalendar/luxon3": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@home-assistant/webawesome": "3.7.0-ha.0",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.1.0",
@@ -63,7 +62,6 @@
"@lit-labs/virtualizer": "2.1.1",
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/web": "2.4.1",
@@ -72,8 +70,8 @@
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.2.1",
"@tsparticles/preset-links": "4.2.1",
"@tsparticles/engine": "4.1.3",
"@tsparticles/preset-links": "4.1.3",
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
@@ -127,19 +125,19 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "8.0.0",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-runtime": "8.0.0",
"@babel/preset-env": "8.0.0",
"@babel/core": "7.29.7",
"@babel/helper-define-polyfill-provider": "0.6.8",
"@babel/plugin-transform-runtime": "7.29.7",
"@babel/preset-env": "7.29.7",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.62.0",
"@html-eslint/eslint-plugin": "0.61.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.15",
"@rspack/core": "2.0.8",
"@rsdoctor/rspack-plugin": "1.5.12",
"@rspack/core": "2.0.6",
"@rspack/dev-server": "2.0.3",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
@@ -151,16 +149,16 @@
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.2",
"@types/luxon": "3.7.1",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@vitest/coverage-v8": "4.1.9",
"@vitest/coverage-v8": "4.1.8",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "patch:babel-plugin-template-html-minifier@npm%3A4.1.0#~/.yarn/patches/babel-plugin-template-html-minifier-npm-4.1.0-9a3c00055a.patch",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.5.0",
"eslint": "10.4.1",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.16.2",
@@ -188,7 +186,7 @@
"lodash.template": "4.18.1",
"map-stream": "0.0.7",
"pinst": "3.0.0",
"prettier": "3.8.4",
"prettier": "3.8.3",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.0.0",
@@ -196,9 +194,9 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.61.1",
"typescript-eslint": "8.61.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.9",
"vitest": "4.1.8",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
@@ -208,14 +206,14 @@
"lit-html": "3.3.3",
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.21",
"@fullcalendar/daygrid": "6.1.20",
"globals": "17.6.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"
},
"packageManager": "yarn@4.17.0",
"packageManager": "yarn@4.16.0",
"volta": {
"node": "24.17.0"
"node": "24.16.0"
}
}
-11
View File
@@ -1,11 +0,0 @@
#!/usr/bin/env bash
# Safe bash settings
# -e Exit on command fail
# -u Exit on unset variable
# -o pipefail Exit if piped command has error code
set -eu -o pipefail
cd "$(dirname "$0")/.."
./node_modules/.bin/gulp gen-numeric-device-classes
+1 -2
View File
@@ -4,8 +4,7 @@ import { ensureArray } from "../array/ensure-array";
import { isComponentLoaded } from "./is_component_loaded";
export const canShowPage = (hass: HomeAssistant, page: PageNavigation) =>
(isCore(page) || isLoadedIntegration(hass, page)) &&
(!page.filter || page.filter(hass));
isCore(page) || isLoadedIntegration(hass, page);
export const isLoadedIntegration = (
hass: HomeAssistant,
-19
View File
@@ -110,25 +110,6 @@ export const DOMAINS_WITH_DYNAMIC_PICTURE = new Set([
"media_player",
]);
/** Domains that use a timestamp for state. */
export const TIMESTAMP_STATE_DOMAINS = new Set([
"ai_task",
"button",
"conversation",
"event",
"image",
"infrared",
"input_button",
"notify",
"radio_frequency",
"scene",
"stt",
"tag",
"tts",
"wake_word",
"datetime",
]);
/** Temperature units. */
export const UNIT_C = "°C";
export const UNIT_F = "°F";
@@ -1,29 +0,0 @@
import { Task, type TaskConfig } from "@lit/task";
import type { ReactiveControllerHost } from "lit";
/**
* A `@lit/task` Task with a sticky `resolved` flag: false until the task has
* completed once, then true. Lets callers tell "still loading" apart from
* "resolved with an empty value" without a null sentinel, while keeping the
* previous value during a re-run.
*/
export class AsyncValueTask<T extends readonly unknown[], R> extends Task<
T,
R
> {
private _resolved = false;
constructor(host: ReactiveControllerHost, config: TaskConfig<T, R>) {
super(host, {
...config,
onComplete: (value) => {
this._resolved = true;
config.onComplete?.(value);
},
});
}
public get resolved(): boolean {
return this._resolved;
}
}
+5 -6
View File
@@ -3,24 +3,23 @@ import type { FrontendLocaleData } from "../../data/translation";
import { selectUnit } from "../util/select-unit";
const formatRelTimeMem = memoizeOne(
(locale: FrontendLocaleData, style: Intl.RelativeTimeFormatStyle) =>
new Intl.RelativeTimeFormat(locale.language, { numeric: "auto", style })
(locale: FrontendLocaleData) =>
new Intl.RelativeTimeFormat(locale.language, { numeric: "auto" })
);
export const relativeTime = (
from: Date,
locale: FrontendLocaleData,
to?: Date,
includeTense = true,
style: Intl.RelativeTimeFormatStyle = "long"
includeTense = true
): string => {
const diff = selectUnit(from, to, locale);
if (includeTense) {
return formatRelTimeMem(locale, style).format(diff.value, diff.unit);
return formatRelTimeMem(locale).format(diff.value, diff.unit);
}
return Intl.NumberFormat(locale.language, {
style: "unit",
unit: diff.unit,
unitDisplay: style,
unitDisplay: "long",
}).format(Math.abs(diff.value));
};
+2 -1
View File
@@ -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", url: string, type?: "module") =>
const _load = (tag: "link" | "script" | "img", 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,4 +33,5 @@ const _load = (tag: "link" | "script", 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");
+41
View File
@@ -0,0 +1,41 @@
/**
* 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);
}
+13
View File
@@ -3,6 +3,8 @@ 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,
@@ -43,6 +45,17 @@ 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}${
+3
View File
@@ -0,0 +1,3 @@
/** An empty image which can be set as src of an img element. */
export const emptyImageBase64 =
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
@@ -60,17 +60,6 @@ export const computeAttributeValueToParts = (
return [{ type: "value", value: localize("state.default.unknown") }];
}
// Device class attribute, return the integration's translated name
if (attribute === "device_class" && typeof attributeValue === "string") {
const domain = computeStateDomain(stateObj);
const deviceClassName = localize(
`component.${domain}.entity_component.${attributeValue}.name`
);
if (deviceClassName) {
return [{ type: "value", value: deviceClassName }];
}
}
// Number value, return formatted number
if (typeof attributeValue === "number") {
const domain = computeStateDomain(stateObj);
@@ -1,7 +1,6 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { unitFromParts } from "./value_parts";
interface EntityUnitStubConfig {
entity: string;
@@ -41,5 +40,5 @@ export const computeEntityUnitDisplay = (
? hass.formatEntityAttributeValueToParts(stateObj, config.attribute)
: hass.formatEntityStateToParts(stateObj);
return unitFromParts(parts);
return parts.find((part) => part.type === "unit")?.value ?? "";
};
+47 -35
View File
@@ -17,33 +17,13 @@ import {
import { blankBeforeUnit } from "../translations/blank_before_unit";
import type { LocalizeFunc } from "../translations/localize";
import { computeDomain } from "./compute_domain";
import {
isNumericSensorDeviceClass,
SENSOR_TIMESTAMP_DEVICE_CLASSES,
} from "../../data/sensor";
import { TIMESTAMP_STATE_DOMAINS } from "../const";
// Domains whose state is a timezone-agnostic date and/or time string.
const DATE_TIME_DOMAINS = new Set(["date", "input_datetime", "time"]);
// 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"];
import { SENSOR_TIMESTAMP_DEVICE_CLASSES } from "../../data/sensor";
export const computeStateDisplay = (
localize: LocalizeFunc,
stateObj: HassEntity,
locale: FrontendLocaleData,
sensorNumericDeviceClasses: string[],
config: HassConfig,
entities: HomeAssistant["entities"],
state?: string
@@ -54,6 +34,7 @@ export const computeStateDisplay = (
return computeStateDisplayFromEntityAttributes(
localize,
locale,
sensorNumericDeviceClasses,
config,
entity,
stateObj.entity_id,
@@ -65,6 +46,7 @@ export const computeStateDisplay = (
export const computeStateDisplayFromEntityAttributes = (
localize: LocalizeFunc,
locale: FrontendLocaleData,
sensorNumericDeviceClasses: string[],
config: HassConfig,
entity: EntityRegistryDisplayEntry | undefined,
entityId: string,
@@ -74,6 +56,7 @@ export const computeStateDisplayFromEntityAttributes = (
const parts = computeStateToPartsFromEntityAttributes(
localize,
locale,
sensorNumericDeviceClasses,
config,
entity,
entityId,
@@ -86,6 +69,7 @@ export const computeStateDisplayFromEntityAttributes = (
const computeStateToPartsFromEntityAttributes = (
localize: LocalizeFunc,
locale: FrontendLocaleData,
sensorNumericDeviceClasses: string[],
config: HassConfig,
entity: EntityRegistryDisplayEntry | undefined,
entityId: string,
@@ -102,15 +86,15 @@ const computeStateToPartsFromEntityAttributes = (
}
const domain = computeDomain(entityId);
const isNumberDomain = NUMERICAL_DOMAINS.includes(domain);
const isSensorDomain = domain === "sensor";
// Numeric values (by attributes, number domain,
// or numeric sensor device class) use formatNumber.
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`
if (
isNumericFromAttributes(attributes) ||
isNumberDomain ||
(isSensorDomain && isNumericSensorDeviceClass(attributes.device_class))
isNumericFromAttributes(
attributes,
domain === "sensor" ? sensorNumericDeviceClasses : []
) ||
is_number_domain
) {
// state is duration
if (
@@ -154,14 +138,24 @@ 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 = MONETARY_TYPE_MAP[part.type];
const type = TYPE_MAP[part.type];
if (!type) continue;
const last = valueParts[valueParts.length - 1];
// Merge consecutive value parts so the number stays a single part
// (e.g. "-" + "12" + "." + "00" → "-12.00")
// Merge consecutive value parts (e.g. "-" + "12" + "." + "00" → "-12.00")
if (type === "value" && last?.type === "value") {
last.value += part.value;
} else {
@@ -197,7 +191,7 @@ const computeStateToPartsFromEntityAttributes = (
return [{ type: "value", value: value }];
}
if (DATE_TIME_DOMAINS.has(domain)) {
if (["date", "input_datetime", "time"].includes(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`.
@@ -256,7 +250,23 @@ const computeStateToPartsFromEntityAttributes = (
// state is a timestamp
if (
TIMESTAMP_STATE_DOMAINS.has(domain) ||
[
"ai_task",
"button",
"conversation",
"event",
"image",
"infrared",
"input_button",
"notify",
"radio_frequency",
"scene",
"stt",
"tag",
"tts",
"wake_word",
"datetime",
].includes(domain) ||
(domain === "sensor" &&
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(attributes.device_class))
) {
@@ -297,6 +307,7 @@ export const computeStateToParts = (
localize: LocalizeFunc,
stateObj: HassEntity,
locale: FrontendLocaleData,
sensorNumericDeviceClasses: string[],
config: HassConfig,
entities: HomeAssistant["entities"],
state?: string
@@ -307,6 +318,7 @@ export const computeStateToParts = (
return computeStateToPartsFromEntityAttributes(
localize,
locale,
sensorNumericDeviceClasses,
config,
entity,
stateObj.entity_id,
+23
View File
@@ -0,0 +1,23 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { supportsFeature } from "./supports-feature";
export type FeatureClassNames<T extends number = number> = Partial<
Record<T, string>
>;
// Expects classNames to be an object mapping feature-bit -> className
export const featureClassNames = (
stateObj: HassEntity,
classNames: FeatureClassNames
) => {
if (!stateObj || !stateObj.attributes.supported_features) {
return "";
}
return Object.keys(classNames)
.map((feature) =>
supportsFeature(stateObj, Number(feature)) ? classNames[feature] : ""
)
.filter((attr) => attr !== "")
.join(" ");
};
-56
View File
@@ -1,56 +0,0 @@
import { AITaskEntityFeature } from "../../data/ai_task";
import { AlarmControlPanelEntityFeature } from "../../data/alarm_control_panel";
import { AssistSatelliteEntityFeature } from "../../data/assist_satellite";
import { CalendarEntityFeature } from "../../data/calendar";
import { CameraEntityFeature } from "../../data/camera";
import { ClimateEntityFeature } from "../../data/climate";
import { ConversationEntityFeature } from "../../data/conversation";
import { CoverEntityFeature } from "../../data/cover";
import { FanEntityFeature } from "../../data/fan";
import { HumidifierEntityFeature } from "../../data/humidifier";
import { LawnMowerEntityFeature } from "../../data/lawn_mower";
import { LightEntityFeature } from "../../data/light";
import { LockEntityFeature } from "../../data/lock";
import { MediaPlayerEntityFeature } from "../../data/media-player";
import { NotifyEntityFeature } from "../../data/notify";
import { RemoteEntityFeature } from "../../data/remote";
import { SirenEntityFeature } from "../../data/siren";
import { TodoListEntityFeature } from "../../data/todo";
import { UpdateEntityFeature } from "../../data/update";
import { VacuumEntityFeature } from "../../data/vacuum";
import { ValveEntityFeature } from "../../data/valve";
import { WaterHeaterEntityFeature } from "../../data/water_heater";
import { WeatherEntityFeature } from "../../data/weather";
export type FeatureEnum = Record<string | number, string | number>;
const DOMAIN_ENUMS = {
ai_task: AITaskEntityFeature,
alarm_control_panel: AlarmControlPanelEntityFeature,
assist_satellite: AssistSatelliteEntityFeature,
calendar: CalendarEntityFeature,
camera: CameraEntityFeature,
climate: ClimateEntityFeature,
conversation: ConversationEntityFeature,
cover: CoverEntityFeature,
fan: FanEntityFeature,
humidifier: HumidifierEntityFeature,
lawn_mower: LawnMowerEntityFeature,
light: LightEntityFeature,
lock: LockEntityFeature,
media_player: MediaPlayerEntityFeature,
notify: NotifyEntityFeature,
remote: RemoteEntityFeature,
siren: SirenEntityFeature,
todo: TodoListEntityFeature,
update: UpdateEntityFeature,
vacuum: VacuumEntityFeature,
valve: ValveEntityFeature,
water_heater: WaterHeaterEntityFeature,
weather: WeatherEntityFeature,
};
export function getFeatures(domain: string): FeatureEnum | undefined {
const enumObj = DOMAIN_ENUMS[domain] as FeatureEnum;
return enumObj;
}
+76 -84
View File
@@ -22,13 +22,16 @@ export const FIXED_DOMAIN_STATES = {
assist_satellite: ["idle", "listening", "responding", "processing"],
automation: ["on", "off"],
binary_sensor: ["on", "off"],
button: [],
calendar: ["on", "off"],
camera: ["idle", "recording", "streaming"],
cover: ["closed", "closing", "open", "opening"],
device_tracker: ["home", "not_home"],
fan: ["on", "off"],
humidifier: ["on", "off"],
infrared: [],
input_boolean: ["on", "off"],
input_button: [],
lawn_mower: ["error", "paused", "mowing", "returning", "docked"],
light: ["on", "off"],
lock: [
@@ -53,6 +56,7 @@ export const FIXED_DOMAIN_STATES = {
plant: ["ok", "problem"],
radio_frequency: [],
remote: ["on", "off"],
scene: [],
schedule: ["on", "off"],
script: ["on", "off"],
siren: ["on", "off"],
@@ -286,81 +290,6 @@ export const getStatesDomain = (
return result;
};
// Maps a value attribute (or the main state, keyed `_`) to the attribute listing
// its options. Naming is irregular per domain, so it's mapped explicitly.
export const DOMAIN_OPTIONS_ATTRIBUTES: Record<
string,
Record<string, string>
> = {
climate: {
_: "hvac_modes",
fan_mode: "fan_modes",
preset_mode: "preset_modes",
swing_mode: "swing_modes",
swing_horizontal_mode: "swing_horizontal_modes",
},
event: {
event_type: "event_types",
},
fan: {
preset_mode: "preset_modes",
},
humidifier: {
mode: "available_modes",
},
input_select: {
_: "options",
},
select: {
_: "options",
},
light: {
effect: "effect_list",
color_mode: "supported_color_modes",
},
media_player: {
sound_mode: "sound_mode_list",
source: "source_list",
},
remote: {
current_activity: "activity_list",
},
sensor: {
_: "options",
},
vacuum: {
fan_speed: "fan_speed_list",
},
water_heater: {
_: "operation_list",
operation_mode: "operation_list",
},
};
const DOMAIN_VALUE_ATTRIBUTES: Record<
string,
Record<string, string>
> = Object.fromEntries(
Object.entries(DOMAIN_OPTIONS_ATTRIBUTES).map(([domain, mapping]) => [
domain,
Object.fromEntries(
Object.entries(mapping).map(([value, list]) => [list, value])
),
])
);
// value attribute (or main state) → its options-list attribute
export const getOptionsAttribute = (
domain: string,
attribute?: string
): string | undefined => DOMAIN_OPTIONS_ATTRIBUTES[domain]?.[attribute ?? "_"];
// options-list attribute → its value attribute (`_` = main state)
export const getValueAttribute = (
domain: string,
optionsAttribute: string
): string | undefined => DOMAIN_VALUE_ATTRIBUTES[domain]?.[optionsAttribute];
export const getStates = (
hass: HomeAssistant,
state: HassEntity,
@@ -373,15 +302,78 @@ export const getStates = (
result.push(...getStatesDomain(hass, domain, attribute));
// Dynamic values based on the entities
const optionsAttribute = getOptionsAttribute(domain, attribute);
if (optionsAttribute) {
const options = state.attributes[optionsAttribute];
// Sensors only expose their options when their device class is `enum`.
const enumSensor =
domain !== "sensor" || state.attributes.device_class === "enum";
if (enumSensor && Array.isArray(options)) {
result.push(...options);
}
switch (domain) {
case "climate":
if (!attribute) {
result.push(...state.attributes.hvac_modes);
} else if (attribute === "fan_mode") {
result.push(...state.attributes.fan_modes);
} else if (attribute === "preset_mode") {
result.push(...state.attributes.preset_modes);
} else if (attribute === "swing_mode") {
result.push(...state.attributes.swing_modes);
} else if (attribute === "swing_horizontal_mode") {
result.push(...state.attributes.swing_horizontal_modes);
}
break;
case "event":
if (attribute === "event_type") {
result.push(...state.attributes.event_types);
}
break;
case "fan":
if (attribute === "preset_mode") {
result.push(...state.attributes.preset_modes);
}
break;
case "humidifier":
if (attribute === "mode") {
result.push(...state.attributes.available_modes);
}
break;
case "input_select":
case "select":
if (!attribute) {
result.push(...state.attributes.options);
}
break;
case "light":
if (attribute === "effect" && state.attributes.effect_list) {
result.push(...state.attributes.effect_list);
} else if (
attribute === "color_mode" &&
state.attributes.supported_color_modes
) {
result.push(...state.attributes.supported_color_modes);
}
break;
case "media_player":
if (attribute === "sound_mode") {
result.push(...state.attributes.sound_mode_list);
} else if (attribute === "source") {
result.push(...state.attributes.source_list);
}
break;
case "remote":
if (attribute === "current_activity") {
result.push(...state.attributes.activity_list);
}
break;
case "sensor":
if (!attribute && state.attributes.device_class === "enum") {
result.push(...state.attributes.options);
}
break;
case "vacuum":
if (attribute === "fan_speed") {
result.push(...state.attributes.fan_speed_list);
}
break;
case "water_heater":
if (!attribute || attribute === "operation_mode") {
result.push(...state.attributes.operation_list);
}
break;
}
return [...new Set(result)];
+10 -2
View File
@@ -1,13 +1,21 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { OFF, UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { computeDomain } from "./compute_domain";
import { TIMESTAMP_STATE_DOMAINS } from "../const";
export function stateActive(stateObj: HassEntity, state?: string): boolean {
const domain = computeDomain(stateObj.entity_id);
const compareState = state !== undefined ? state : stateObj?.state;
if (TIMESTAMP_STATE_DOMAINS.has(domain)) {
if (
[
"button",
"event",
"infrared",
"input_button",
"radio_frequency",
"scene",
].includes(domain)
) {
return compareState !== UNAVAILABLE;
}
+5 -6
View File
@@ -4,10 +4,9 @@ import { updateIsInstalling } from "../../data/update";
export const updateIcon = (stateObj: HassEntity, state?: string) => {
const compareState = state ?? stateObj.state;
// 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";
return compareState === "on"
? updateIsInstalling(stateObj as UpdateEntity)
? "mdi:package-down"
: "mdi:package-up"
: "mdi:package";
};
-29
View File
@@ -1,29 +0,0 @@
import type { ValuePart } from "../../types";
// Joins every part except the unit, keeping native order so the sign and
// grouping stay with the value (e.g. "-2,548.14").
export const valueFromParts = (parts: ValuePart[]): string =>
parts
.filter((part) => part.type !== "unit")
.map((part) => part.value)
.join("")
.trim();
export const unitFromParts = (parts: ValuePart[]): string =>
parts.find((part) => part.type === "unit")?.value ?? "";
export type UnitPosition = "before" | "after";
// Whether the unit sits before or after the value in the locale's native order
// (e.g. "$5" / "€ 5" → "before", "5 €" / "5 %" → "after").
export const unitPosition = (parts: ValuePart[]): UnitPosition => {
const unitIndex = parts.findIndex((part) => part.type === "unit");
if (unitIndex === -1) {
return "after";
}
const lastValueIndex = parts.reduceRight(
(acc, part, i) => (acc === -1 && part.type === "value" ? i : acc),
-1
);
return unitIndex < lastValueIndex ? "before" : "after";
};
+8 -23
View File
@@ -14,8 +14,12 @@ export const isNumericState = (stateObj: HassEntity): boolean =>
isNumericFromAttributes(stateObj.attributes);
export const isNumericFromAttributes = (
attributes: HassEntityAttributeBase
): boolean => !!attributes.unit_of_measurement || !!attributes.state_class;
attributes: HassEntityAttributeBase,
numericDeviceClasses?: string[]
): boolean =>
!!attributes.unit_of_measurement ||
!!attributes.state_class ||
(numericDeviceClasses || []).includes(attributes.device_class || "");
export const numberFormatToLocale = (
localeOptions: FrontendLocaleData
@@ -36,25 +40,6 @@ 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.
*
@@ -90,7 +75,7 @@ export const formatNumberToParts = (
localeOptions?.number_format !== NumberFormat.none &&
!Number.isNaN(Number(num))
) {
return getNumberFormatter(
return new Intl.NumberFormat(
locale,
getDefaultFormatOptions(num, options)
).formatToParts(Number(num));
@@ -102,7 +87,7 @@ export const formatNumberToParts = (
localeOptions?.number_format === NumberFormat.none
) {
// If NumberFormat is none, use en-US format without grouping.
return getNumberFormatter(
return new Intl.NumberFormat(
"en-US",
getDefaultFormatOptions(num, {
...options,
+7 -23
View File
@@ -1,5 +1,5 @@
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
import type { ItemType, RelatedResult } from "../../data/search";
import type { RelatedResult } from "../../data/search";
export interface RelatedIdSets {
areas: Set<string>;
@@ -8,30 +8,14 @@ export interface RelatedIdSets {
}
/**
* Build a set of related IDs, merging in the current (queried) item.
* `search/related` does not echo the queried item back, but it is the closest
* related item (e.g. a card editor's own entity), so it is merged into the
* matching group when it is an area, device, or entity.
* Build a set of related IDs for a given related result.
* @param related - The related result to build the sets from.
* @param current - The queried item to merge in.
* @returns The related ID sets, including the current item.
* @returns The related ID sets.
*/
export const buildRelatedIdSets = (
related?: RelatedResult,
current?: { itemType: ItemType; itemId: string }
): RelatedIdSets => ({
areas: new Set([
...(related?.area || []),
...(current?.itemType === "area" ? [current.itemId] : []),
]),
devices: new Set([
...(related?.device || []),
...(current?.itemType === "device" ? [current.itemId] : []),
]),
entities: new Set([
...(related?.entity || []),
...(current?.itemType === "entity" ? [current.itemId] : []),
]),
export const buildRelatedIdSets = (related?: RelatedResult): RelatedIdSets => ({
areas: new Set(related?.area || []),
devices: new Set(related?.device || []),
entities: new Set(related?.entity || []),
});
/**
+20 -3
View File
@@ -46,7 +46,8 @@ export const computeFormatFunctions = async (
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
floors: HomeAssistant["floors"],
sensorNumericDeviceClasses: string[]
): Promise<{
formatEntityState: FormatEntityStateFunc;
formatEntityStateToParts: FormatEntityStateToPartsFunc;
@@ -65,9 +66,25 @@ export const computeFormatFunctions = async (
return {
formatEntityState: (stateObj, state) =>
computeStateDisplay(localize, stateObj, locale, config, entities, state),
computeStateDisplay(
localize,
stateObj,
locale,
sensorNumericDeviceClasses,
config,
entities,
state
),
formatEntityStateToParts: (stateObj, state) =>
computeStateToParts(localize, stateObj, locale, config, entities, state),
computeStateToParts(
localize,
stateObj,
locale,
sensorNumericDeviceClasses,
config,
entities,
state
),
formatEntityAttributeValue: (stateObj, attribute, value) =>
computeAttributeValueDisplay(
localize,
+2
View File
@@ -17,6 +17,8 @@ export type LocalizeKeys =
| `ui.common.${string}`
| `ui.components.calendar.event.rrule.${string}`
| `ui.components.selectors.file.${string}`
| `ui.components.logbook.messages.detected_device_classes.${string}`
| `ui.components.logbook.messages.cleared_device_classes.${string}`
| `ui.dialogs.entity_registry.editor.${string}`
| `ui.dialogs.more_info_control.lawn_mower.${string}`
| `ui.dialogs.more_info_control.vacuum.${string}`
+2 -8
View File
@@ -11,12 +11,6 @@ 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;
@@ -25,8 +19,8 @@ export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
el.style.top = "0";
el.style.left = "0";
el.style.opacity = "0";
container.appendChild(el);
root.appendChild(el);
el.select();
document.execCommand("copy");
container.removeChild(el);
root.removeChild(el);
};
@@ -1,29 +0,0 @@
/**
* 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;
};
-30
View File
@@ -1,30 +0,0 @@
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);
},
});
-26
View File
@@ -1,26 +0,0 @@
/**
* Return a shallow copy of an object with every key removed whose value is
* `undefined` or equals that key's default, so a key left at its default
* (whether absent or explicit) does not count as a difference. A key's default
* comes from `defaults` when present, otherwise `false`.
*
* Non-plain-object values are returned unchanged; only top-level keys are
* compared.
*/
export const stripDefaults = <T>(
value: T,
defaults?: Record<string, unknown>
): T => {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
const defaultValue = defaults && key in defaults ? defaults[key] : false;
if (val === undefined || val === defaultValue) {
continue;
}
result[key] = val;
}
return result as T;
};
@@ -4,10 +4,10 @@ type ResultCache<T> = Record<string, Promise<T> | undefined>;
/**
* Call a function with result caching per entity.
* @param cacheKey key to namespace the cache
* @param cacheKey key to store the cache on hass object
* @param cacheTime time to cache the results
* @param func function to fetch the data
* @param hass Home Assistant object (or slice) the cache is keyed on
* @param hass Home Assistant object
* @param entityId entity to fetch data for
* @param args extra arguments to pass to the function to fetch the data
* @returns
@@ -15,12 +15,8 @@ type ResultCache<T> = Record<string, Promise<T> | undefined>;
export const timeCacheEntityPromiseFunc = async <T>(
cacheKey: string,
cacheTime: number,
func: (
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
entityId: string,
...args: any[]
) => Promise<T>,
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
func: (hass: HomeAssistant, entityId: string, ...args: any[]) => Promise<T>,
hass: HomeAssistant,
entityId: string,
...args: any[]
): Promise<T> => {
@@ -43,11 +39,11 @@ export const timeCacheEntityPromiseFunc = async <T>(
// When successful, set timer to clear cache
() =>
setTimeout(() => {
cache[entityId] = undefined;
cache![entityId] = undefined;
}, cacheTime),
// On failure, clear cache right away
() => {
cache[entityId] = undefined;
cache![entityId] = undefined;
}
);
@@ -16,12 +16,14 @@ interface CacheResult<T> {
* @param args extra arguments to pass to the function to fetch the data
* @returns
*/
export const timeCachePromiseFunc = async <T, H = HomeAssistant>(
export const timeCachePromiseFunc = async <T>(
cacheKey: string,
cacheTime: number,
func: (hass: H, ...args: any[]) => Promise<T>,
generateCacheKey: ((hass: H, lastResult: T) => unknown) | undefined,
hass: H,
func: (hass: HomeAssistant, ...args: any[]) => Promise<T>,
generateCacheKey:
| ((hass: HomeAssistant, lastResult: T) => unknown)
| undefined,
hass: HomeAssistant,
...args: any[]
): Promise<T> => {
const anyHass = hass as any;
@@ -1,12 +1,5 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import "../ha-svg-icon";
import {
mdiAlertCircle,
mdiCheckCircle,
mdiCloseCircle,
mdiHelpCircle,
} from "@mdi/js";
export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
@@ -26,59 +19,46 @@ export class HaAutomationRowLiveTest extends LitElement {
@property() public label = "";
private get _iconPath() {
switch (this.state) {
case "pass":
return mdiCheckCircle;
case "fail":
return mdiCloseCircle;
case "invalid":
return mdiAlertCircle;
default:
return mdiHelpCircle;
}
}
protected render() {
return html`
<div id="indicator" role="status" tabindex="0" aria-label=${this.label}>
<ha-svg-icon .path=${this._iconPath}></ha-svg-icon>
</div>
<div
id="indicator"
role="status"
tabindex="0"
aria-label=${this.label}
></div>
`;
}
static styles = css`
:host {
position: absolute;
top: -8px;
inset-inline-end: -8px;
top: -5px;
inset-inline-end: -6px;
display: inline-block;
}
#indicator {
width: 16px;
height: 16px;
display: grid;
place-items: center;
width: 10px;
height: 10px;
border-radius: var(--ha-border-radius-circle);
border: var(--ha-border-width-md) solid;
box-sizing: border-box;
background-color: var(--card-background-color);
box-shadow: 0 0 0 2px var(--card-background-color);
transition: all var(--ha-animation-duration-normal) ease-in-out;
}
#indicator ha-svg-icon {
width: 16px;
height: 16px;
--mdc-icon-size: 16px;
}
:host([state="pass"]) #indicator {
color: var(--ha-color-green-60);
background-color: var(--ha-color-green-60);
border-color: var(--ha-color-green-60);
}
:host([state="fail"]) #indicator {
color: var(--ha-color-orange-60);
border-color: var(--ha-color-orange-60);
}
:host([state="invalid"]) #indicator {
color: var(--ha-color-red-60);
border-color: var(--ha-color-red-60);
}
:host([state="unknown"]) #indicator {
color: var(--ha-color-neutral-60);
border-color: var(--ha-color-neutral-60);
}
`;
}
@@ -1,18 +1,16 @@
import { consume, type ContextType } from "@lit/context";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import "./ha-progress-button";
import { apiContext } from "../../data/context";
import type { HomeAssistant } from "../../types";
import { fireEvent } from "../../common/dom/fire_event";
import type { Appearance } from "../ha-button";
@customElement("ha-call-service-button")
class HaCallServiceButton extends LitElement {
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public disabled = false;
@@ -58,7 +56,7 @@ class HaCallServiceButton extends LitElement {
this.shadowRoot!.querySelector("ha-progress-button")!;
try {
await this._api.callService(
await this.hass.callService(
this.domain,
this.service,
this.data,
+4 -14
View File
@@ -394,18 +394,6 @@ 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)"
@@ -425,10 +413,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 {
@@ -438,7 +426,9 @@ export class HaChartBase extends LitElement {
noLabelClick = item.noLabelClick ?? false;
}
const labelClickable = this.clickLabelForMoreInfo && !noLabelClick;
const dataset = datasetById.get(id) ?? datasetByName.get(id);
const dataset =
datasets.find((d) => d.id === id) ??
datasets.find((d) => d.name === id);
itemStyle = {
color: dataset?.color as string,
...(dataset?.itemStyle as { borderColor?: string }),
@@ -1,481 +0,0 @@
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),
};
}
+451 -34
View File
@@ -5,17 +5,15 @@ 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 } from "../../data/history";
import type { LineChartEntity, LineChartState } 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 {
CLIMATE_MODE_CONFIGS,
generateStateHistoryChartLineData,
} from "./state-history-chart-line-data";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
import type { HaECOption } from "../../resources/echarts/echarts";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
@@ -25,9 +23,22 @@ 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.
@@ -136,14 +147,6 @@ 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 ||
@@ -151,7 +154,9 @@ export class StateHistoryChartLine extends LitElement {
) {
return;
}
const param = paramsBySeriesIndex.get(index);
const param = params.find(
(p: Record<string, any>) => p.seriesIndex === index
);
if (param) {
datapoints.push(param);
return;
@@ -415,37 +420,449 @@ export class StateHistoryChartLine extends LitElement {
}
private _generateData() {
if (this.data.length === 0) {
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) {
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 = 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(),
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);
});
if (!data) {
return;
}
this._yAxisFractionDigits = data.yAxisFractionDigits;
this._chartData = data.datasets;
this._entityIds = data.entityIds;
this._datasetToDataIndex = data.datasetToDataIndex;
this._visualMap = data.visualMap;
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;
}
private _formatYAxisLabel = (value: number) => {
const label = formatNumber(value, this.hass.locale, {
minimumFractionDigits: value === 0 ? 0 : this._yAxisFractionDigits,
maximumFractionDigits: this._yAxisFractionDigits,
});
const width = measureTextWidth(label, 12) + 5;
@@ -1,465 +0,0 @@
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),
};
}
+391 -27
View File
@@ -1,12 +1,14 @@
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";
@@ -25,7 +27,13 @@ import type {
StatisticsMetaData,
StatisticType,
} from "../../data/recorder";
import { getStatisticMetadata, isExternalStatistic } from "../../data/recorder";
import {
getDisplayUnit,
getStatisticLabel,
getStatisticMetadata,
isExternalStatistic,
statisticsHaveType,
} from "../../data/recorder";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
@@ -33,7 +41,8 @@ import type { CustomLegendOption } from "./ha-chart-base";
import "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { generateStatisticsChartData } from "./statistics-chart-data";
import { fillDataGapsAndRoundCaps } from "./round-caps";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
mean: "mean",
@@ -494,35 +503,391 @@ export class StatisticsChart extends LitElement {
this.metadata ||
(await this._getStatisticsMetaData(Object.keys(this.statisticsData)));
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(),
});
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;
if (!data) {
if (statisticsData.length === 0) {
return;
}
this.unit = data.unit;
this._yAxisFractionDigits = data.yAxisFractionDigits;
this._chartData = data.datasets;
if (data.legendData.length !== this._legendData?.length) {
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) {
// only update the legend if it has changed or it will trigger options update
this._legendData =
data.legendData.length > 1
? data.legendData.map(({ id, name, noLabelClick }) => ({
legendData.length > 1
? legendData.map(({ id, name, noLabelClick }) => ({
id,
name,
noLabelClick,
@@ -530,7 +895,7 @@ export class StatisticsChart extends LitElement {
: // if there is only one entity, let the base chart handle the legend
undefined;
}
this._statisticIds = data.statisticIds;
this._statisticIds = statisticIds;
}
private _clampYAxis(value?: number | ((values: any) => number)) {
@@ -552,7 +917,6 @@ export class StatisticsChart extends LitElement {
private _formatYAxisLabel = (value: number) =>
formatNumber(value, this.hass.locale, {
minimumFractionDigits: value === 0 ? 0 : this._yAxisFractionDigits,
maximumFractionDigits: this._yAxisFractionDigits,
});
+3 -5
View File
@@ -79,11 +79,9 @@ function computeTimelineEnumColor(
const domain = computeStateDomain(stateObj);
const states =
FIXED_DOMAIN_STATES[domain] ||
((domain === "sensor" && stateObj.attributes.device_class === "enum") ||
domain === "select" ||
domain === "input_select"
? stateObj.attributes.options
: undefined) ||
(domain === "sensor" &&
stateObj.attributes.device_class === "enum" &&
stateObj.attributes.options) ||
[];
const idx = states.indexOf(state);
if (idx === -1) {
+2 -8
View File
@@ -215,16 +215,10 @@ 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 = rowLookup.get(id);
if (row?.selectable !== false && !checkedRows.has(id)) {
const row = this._filteredData?.find((data) => data[this.id] === id);
if (row?.selectable !== false && !this._checkedRows.includes(id)) {
this._checkedRows.push(id);
checkedRows.add(id);
}
});
this._lastSelectedRowId = null;
+19 -59
View File
@@ -1,5 +1,4 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume } from "@lit/context";
import { mdiPlus, mdiShape } from "@mdi/js";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -7,14 +6,10 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import type { RelatedIdSets } from "../../common/search/related-context";
import { relatedContext } from "../../data/context";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import {
entityComboBoxKeys,
getEntities,
markEntitiesRelated,
sortEntitiesByRelatedRank,
type EntityComboBoxItem,
} from "../../data/entity/entity_picker";
import { domainToName } from "../../data/integration";
@@ -136,20 +131,6 @@ export class HaEntityPicker extends LitElement {
@state() private _pendingEntityId?: string;
@state()
@consume({ context: relatedContext, subscribe: true })
private _relatedIdSets?: RelatedIdSets;
private get _hasRelatedContext(): boolean {
const related = this._relatedIdSets;
return (
!!related &&
(related.entities.size > 0 ||
related.devices.size > 0 ||
related.areas.size > 0)
);
}
protected willUpdate(changedProperties: PropertyValues<this>) {
if (
this._pendingEntityId &&
@@ -180,7 +161,11 @@ export class HaEntityPicker extends LitElement {
: undefined;
if (stateObj) {
return html`
<state-badge slot="start" .stateObj=${stateObj}></state-badge>
<state-badge
slot="start"
.stateObj=${stateObj}
.hass=${this.hass}
></state-badge>
`;
}
if (extraOption.icon_path) {
@@ -231,7 +216,11 @@ export class HaEntityPicker extends LitElement {
);
return html`
<state-badge .stateObj=${stateObj} slot="start"></state-badge>
<state-badge
.hass=${this.hass}
.stateObj=${stateObj}
slot="start"
></state-badge>
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
`;
@@ -261,6 +250,7 @@ export class HaEntityPicker extends LitElement {
<state-badge
slot="start"
.stateObj=${item.stateObj}
.hass=${this.hass}
></state-badge>
`}
<span slot="headline">${item.primary}</span>
@@ -343,22 +333,8 @@ export class HaEntityPicker extends LitElement {
})
);
private _sortByRelatedContext = memoizeOne(
(
items: EntityComboBoxItem[],
related: RelatedIdSets,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
language: string
): EntityComboBoxItem[] =>
sortEntitiesByRelatedRank(
markEntitiesRelated(items, related, entities, devices),
language
)
);
private _getItems = () => {
const entityItems = this._getEntitiesMemoized(
const items = this._getEntitiesMemoized(
this.hass,
this.includeDomains,
this.excludeDomains,
@@ -369,23 +345,14 @@ export class HaEntityPicker extends LitElement {
this.excludeEntities,
this.value
);
const sortedItems = this._hasRelatedContext
? this._sortByRelatedContext(
entityItems,
this._relatedIdSets!,
this.hass.entities,
this.hass.devices,
this.hass.locale.language
)
: entityItems;
if (this.extraOptions?.length) {
const resolvedExtras = this.extraOptions.map((opt) => ({
...opt,
stateObj: opt.entity_id ? this.hass.states[opt.entity_id] : undefined,
}));
return [...resolvedExtras, ...sortedItems];
return [...resolvedExtras, ...items];
}
return sortedItems;
return items;
};
private _shouldHideClearIcon() {
@@ -417,7 +384,6 @@ export class HaEntityPicker extends LitElement {
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
.searchKeys=${entityComboBoxKeys}
.noSort=${this._hasRelatedContext}
use-top-label
.addButtonLabel=${this.addButton
? (this.addButtonLabel ??
@@ -436,23 +402,17 @@ export class HaEntityPicker extends LitElement {
search,
filteredItems
) => {
// Float related items to the top by closeness, keeping search relevance
// order within each tier.
const items = this._hasRelatedContext
? sortEntitiesByRelatedRank(filteredItems)
: filteredItems;
// If there is exact match for entity id, put it first
const index = items.findIndex(
const index = filteredItems.findIndex(
(item) => item.stateObj?.entity_id === search
);
if (index === -1) {
return items;
return filteredItems;
}
const [exactMatch] = items.splice(index, 1);
items.unshift(exactMatch);
return items;
const [exactMatch] = filteredItems.splice(index, 1);
filteredItems.unshift(exactMatch);
return filteredItems;
};
public async open() {
+5 -6
View File
@@ -1,4 +1,3 @@
import { consume, type ContextType } from "@lit/context";
import { mdiFlash, mdiFlashOff } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
@@ -7,9 +6,9 @@ import { customElement, property, state } from "lit/decorators";
import { STATES_OFF } from "../../common/const";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { apiContext } from "../../data/context";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { forwardHaptic } from "../../data/haptics";
import type { HomeAssistant } from "../../types";
import "../ha-formfield";
import "../ha-icon-button";
import "../ha-switch";
@@ -30,8 +29,8 @@ const isOn = (stateObj?: HassEntity) =>
@customElement("ha-entity-toggle")
export class HaEntityToggle extends LitElement {
@consume({ context: apiContext, subscribe: true })
private _api?: ContextType<typeof apiContext>;
// hass is not a property so that we only re-render on stateObj changes
public hass?: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@@ -119,7 +118,7 @@ export class HaEntityToggle extends LitElement {
// result in the entity to be turned on. Since the state is not changing,
// the resync is not called automatic.
private async _callService(turnOn): Promise<void> {
if (!this._api || !this.stateObj) {
if (!this.hass || !this.stateObj) {
return;
}
forwardHaptic(this, "light");
@@ -150,7 +149,7 @@ export class HaEntityToggle extends LitElement {
this._isOn = turnOn;
try {
await this._api.callService(serviceDomain, service, {
await this.hass.callService(serviceDomain, service, {
entity_id: this.stateObj.entity_id,
});
} finally {
+20 -39
View File
@@ -1,5 +1,3 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import { mdiAlert } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
@@ -8,19 +6,13 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { arrayLiteralIncludes } from "../../common/array/literal-includes";
import secondsToDuration from "../../common/datetime/seconds_to_duration";
import {
consumeEntityRegistryEntry,
consumeLocalize,
} from "../../common/decorators/consume-context-entry";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { unitFromParts, valueFromParts } from "../../common/entity/value_parts";
import { FIXED_DOMAIN_STATES } from "../../common/entity/get_states";
import type { LocalizeFunc } from "../../common/translations/localize";
import { formattersContext } from "../../data/context";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
import { timerTimeRemaining } from "../../data/timer";
import type { HomeAssistant } from "../../types";
import "../ha-label-badge";
import "../ha-state-icon";
@@ -48,15 +40,7 @@ const getTruncatedKey = (domainKey: string, stateKey: string) => {
@customElement("ha-state-label-badge")
export class HaStateLabelBadge extends LitElement {
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters?: ContextType<typeof formattersContext>;
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@state()
@consumeEntityRegistryEntry({ entityIdPath: ["state", "entity_id"] })
private _entry?: EntityRegistryDisplayEntry;
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public state?: HassEntity;
@@ -93,8 +77,10 @@ export class HaStateLabelBadge extends LitElement {
return html`
<ha-label-badge
class="warning"
label=${this._localize("state_badge.default.error")}
description=${this._localize("state_badge.default.entity_not_found")}
label=${this.hass!.localize("state_badge.default.error")}
description=${this.hass!.localize(
"state_badge.default.entity_not_found"
)}
>
<ha-svg-icon .path=${mdiAlert}></ha-svg-icon>
</ha-label-badge>
@@ -108,7 +94,7 @@ export class HaStateLabelBadge extends LitElement {
// 4. Icon determined via entity state
// 5. Value string as fallback
const domain = computeStateDomain(entityState);
const entry = this._entry;
const entry = this.hass?.entities[entityState.entity_id];
const showIcon =
this.icon || this._computeShowIcon(domain, entityState, entry);
@@ -177,23 +163,20 @@ export class HaStateLabelBadge extends LitElement {
case "sun":
case "timer":
return null;
// @ts-expect-error we don't break and go to default
case "sensor":
if (entry?.platform === "moon") {
return null;
}
break;
// eslint-disable-next-line: disable=no-fallthrough
default:
break;
return entityState.state === UNAVAILABLE ||
entityState.state === UNKNOWN
? "—"
: this.hass!.formatEntityStateToParts(entityState).find(
(part) => part.type === "value"
)?.value;
}
if (entityState.state === UNAVAILABLE || entityState.state === UNKNOWN) {
return "—";
}
if (!this._formatters) {
return null;
}
return valueFromParts(
this._formatters.formatEntityStateToParts(entityState)
);
}
private _computeShowIcon(
@@ -228,11 +211,11 @@ export class HaStateLabelBadge extends LitElement {
) {
// For unavailable states or certain domains, use a special translation that is truncated to fit within the badge label
if (entityState.state === UNAVAILABLE || entityState.state === UNKNOWN) {
return this._localize(`state_badge.default.${entityState.state}`);
return this.hass!.localize(`state_badge.default.${entityState.state}`);
}
const domainStateKey = getTruncatedKey(domain, entityState.state);
if (domainStateKey) {
return this._localize(`state_badge.${domainStateKey}`);
return this.hass!.localize(`state_badge.${domainStateKey}`);
}
// Person and device tracker state can be zone name
if (domain === "person" || domain === "device_tracker") {
@@ -241,12 +224,10 @@ export class HaStateLabelBadge extends LitElement {
if (domain === "timer") {
return secondsToDuration(_timerTimeRemaining);
}
if (!this._formatters) {
return null;
}
return (
unitFromParts(this._formatters.formatEntityStateToParts(entityState)) ||
null
this.hass!.formatEntityStateToParts(entityState).find(
(part) => part.type === "unit"
)?.value || null
);
}
+6 -1
View File
@@ -343,7 +343,11 @@ export class HaStatisticPicker extends LitElement {
return html`
${item.stateObj
? html`
<state-badge .stateObj=${item.stateObj} slot="start"></state-badge>
<state-badge
.hass=${this.hass}
.stateObj=${item.stateObj}
slot="start"
></state-badge>
`
: item.icon_path
? html`
@@ -484,6 +488,7 @@ export class HaStatisticPicker extends LitElement {
<state-badge
slot="start"
.stateObj=${item.stateObj}
.hass=${this.hass}
></state-badge>
`
: nothing}
+15 -28
View File
@@ -1,4 +1,3 @@
import { consume, type ContextType } from "@lit/context";
import { mdiAlert } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
@@ -15,12 +14,13 @@ import {
import { iconColorCSS } from "../../common/style/icon_color_css";
import { cameraUrlWithWidthHeight } from "../../data/camera";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import { connectionContext } from "../../data/context";
import { isBrandUrl } from "../../util/brands-url";
import type { HomeAssistant } from "../../types";
import "../ha-state-icon";
@customElement("state-badge")
export class StateBadge extends LitElement {
public hass?: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@property({ attribute: false }) public overrideIcon?: string;
@@ -36,10 +36,6 @@ export class StateBadge extends LitElement {
// @todo Consider reworking to eliminate need for attribute since it is manipulated internally
@property({ type: Boolean, reflect: true }) public icon = true;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection?: ContextType<typeof connectionContext>;
@state() private _iconStyle: Record<string, string | undefined> = {};
connectedCallback(): void {
@@ -110,15 +106,14 @@ export class StateBadge extends LitElement {
></ha-state-icon>`;
}
public willUpdate(changedProps: PropertyValues) {
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (
!changedProps.has("stateObj") &&
!changedProps.has("overrideImage") &&
!changedProps.has("overrideIcon") &&
!changedProps.has("stateColor") &&
!changedProps.has("color") &&
!changedProps.has("_connection")
!changedProps.has("color")
) {
return;
}
@@ -138,10 +133,12 @@ export class StateBadge extends LitElement {
stateObj.attributes.entity_picture) &&
!this.overrideIcon
) {
let imageUrl = this._resolveImageUrl(
let imageUrl =
stateObj.attributes.entity_picture_local ||
stateObj.attributes.entity_picture
);
stateObj.attributes.entity_picture;
if (this.hass) {
imageUrl = this.hass.hassUrl(imageUrl);
}
if (domain === "camera") {
imageUrl = cameraUrlWithWidthHeight(imageUrl, 80, 80);
}
@@ -182,7 +179,11 @@ export class StateBadge extends LitElement {
}
}
} else if (this.overrideImage) {
backgroundImage = `url(${this._resolveImageUrl(this.overrideImage)})`;
let imageUrl = this.overrideImage;
if (this.hass) {
imageUrl = this.hass.hassUrl(imageUrl);
}
backgroundImage = `url(${imageUrl})`;
this.icon = false;
}
}
@@ -191,20 +192,6 @@ export class StateBadge extends LitElement {
this.style.backgroundImage = backgroundImage;
}
// Sign the image URL via the connection context so brand images
// (/api/brands/...) get their access token. Without a way to sign, a brands
// request would be rejected (and logged/blocked by core), so skip it until
// we can sign.
private _resolveImageUrl(url: string | undefined): string {
if (!url) {
return "";
}
if (this._connection) {
return this._connection.hassUrl(url);
}
return isBrandUrl(url) ? "" : url;
}
protected getClass() {
const cls = new Map(
["has-no-radius", "has-media-image", "has-image"].map((_cls) => [
+1
View File
@@ -24,6 +24,7 @@ class StateInfo extends LitElement {
const name = this.hass.formatEntityName(this.stateObj, { type: "entity" });
return html`<state-badge
.hass=${this.hass}
.stateObj=${this.stateObj}
.stateColor=${true}
.color=${this.color}
@@ -173,6 +173,7 @@ export class HaAreaControlsPicker extends LitElement {
domainItems = multiTermSortedSearch(
domainItems,
searchString,
this._domainSearchKeys,
(item) => item.id,
fuseIndex
);
@@ -225,6 +226,7 @@ export class HaAreaControlsPicker extends LitElement {
entityItems = multiTermSortedSearch(
entityItems,
searchString,
this._entitySearchKeys,
(item) => item.id,
fuseIndex
);
+5 -12
View File
@@ -1,11 +1,10 @@
import { consume, type ContextType } from "@lit/context";
import { mdiTextureBox } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { getAreaContext } from "../common/entity/context/get_area_context";
import { areasContext, floorsContext } from "../data/context";
import type { HomeAssistant } from "../types";
import "./ha-expansion-panel";
import "./ha-items-display-editor";
import type { DisplayItem, DisplayValue } from "./ha-items-display-editor";
@@ -18,13 +17,7 @@ export interface AreasDisplayValue {
@customElement("ha-areas-display-editor")
export class HaAreasDisplayEditor extends LitElement {
@consume({ context: areasContext, subscribe: true })
@state()
private _areas!: ContextType<typeof areasContext>;
@consume({ context: floorsContext, subscribe: true })
@state()
private _floors!: ContextType<typeof floorsContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@@ -42,10 +35,10 @@ export class HaAreasDisplayEditor extends LitElement {
public showNavigationButton = false;
protected render(): TemplateResult {
const areas = Object.values(this._areas);
const areas = Object.values(this.hass.areas);
const items: DisplayItem[] = areas.map((area) => {
const { floor } = getAreaContext(area, this._floors);
const { floor } = getAreaContext(area, this.hass.floors);
return {
value: area.area_id,
label: area.name,
@@ -1,19 +1,15 @@
import { consume, type ContextType } from "@lit/context";
import { mdiDragHorizontalVariant, mdiTextureBox } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { getAreaContext } from "../common/entity/context/get_area_context";
import type { LocalizeFunc } from "../common/translations/localize";
import { areasContext, floorsContext } from "../data/context";
import type { FloorRegistryEntry } from "../data/floor_registry";
import { getFloors } from "../panels/lovelace/strategies/areas/helpers/areas-strategy-helper";
import type { ValueChangedEvent } from "../types";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-expansion-panel";
import "./ha-floor-icon";
import "./ha-items-display-editor";
@@ -34,17 +30,7 @@ const UNASSIGNED_FLOOR = "__unassigned__";
@customElement("ha-areas-floors-display-editor")
export class HaAreasFloorsDisplayEditor extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: areasContext, subscribe: true })
@state()
private _areas!: ContextType<typeof areasContext>;
@consume({ context: floorsContext, subscribe: true })
@state()
private _floors!: ContextType<typeof floorsContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@@ -65,14 +51,13 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
protected render(): TemplateResult {
const groupedAreasItems = this._groupedAreasItems(
this._areas,
this._floors
this.hass.areas,
this.hass.floors
);
const filteredFloors = this._sortedFloors(
this._floors,
this.value?.floors_display?.order,
this._localize
this.hass.floors,
this.value?.floors_display?.order
).filter(
(floor) =>
// Only include floors that have areas assigned to them
@@ -139,14 +124,15 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
private _groupedAreasItems = memoizeOne(
(
areas: ContextType<typeof areasContext>,
floors: ContextType<typeof floorsContext>
hassAreas: HomeAssistant["areas"],
// update items if floors change
_hassFloors: HomeAssistant["floors"]
): Record<string, DisplayItem[]> => {
const areaList = Object.values(areas);
const areas = Object.values(hassAreas);
const groupedItems: Record<string, DisplayItem[]> = areaList.reduce(
const groupedItems: Record<string, DisplayItem[]> = areas.reduce(
(acc, area) => {
const { floor } = getAreaContext(area, floors);
const { floor } = getAreaContext(area, this.hass.floors);
const floorId = floor?.floor_id ?? UNASSIGNED_FLOOR;
if (!acc[floorId]) {
@@ -169,24 +155,23 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
private _sortedFloors = memoizeOne(
(
floors: ContextType<typeof floorsContext>,
order: string[] | undefined,
localize: LocalizeFunc
hassFloors: HomeAssistant["floors"],
order: string[] | undefined
): FloorRegistryEntry[] => {
const sortedFloors = getFloors(floors, order);
const noFloors = sortedFloors.length === 0;
sortedFloors.push({
const floors = getFloors(hassFloors, order);
const noFloors = floors.length === 0;
floors.push({
floor_id: UNASSIGNED_FLOOR,
name: noFloors
? localize("ui.panel.lovelace.strategy.areas.areas")
: localize("ui.panel.lovelace.strategy.areas.other_areas"),
? this.hass.localize("ui.panel.lovelace.strategy.areas.areas")
: this.hass.localize("ui.panel.lovelace.strategy.areas.other_areas"),
icon: null,
level: null,
aliases: [],
created_at: 0,
modified_at: 0,
});
return sortedFloors;
return floors;
}
);
@@ -195,9 +180,8 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
const newIndex = ev.detail.newIndex;
const oldIndex = ev.detail.oldIndex;
const floorIds = this._sortedFloors(
this._floors,
this.value?.floors_display?.order,
this._localize
this.hass.floors,
this.value?.floors_display?.order
).map((floor) => floor.floor_id);
const newOrder = [...floorIds];
const movedFloorId = newOrder.splice(oldIndex, 1)[0];
@@ -220,9 +204,8 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
const currentFloorId = (ev.currentTarget as any).floorId;
const floorIds = this._sortedFloors(
this._floors,
this.value?.floors_display?.order,
this._localize
this.hass.floors,
this.value?.floors_display?.order
).map((floor) => floor.floor_id);
const oldAreaDisplay = this.value?.areas_display ?? {};
@@ -240,14 +223,14 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
continue;
}
const hidden = oldHidden.filter((areaId) => {
const id = this._areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
const id = this.hass.areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
return id === floorId;
});
if (hidden?.length) {
newHidden.push(...hidden);
}
const order = oldOrder.filter((areaId) => {
const id = this._areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
const id = this.hass.areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
return id === floorId;
});
if (order?.length) {

Some files were not shown because too many files have changed in this diff Show More