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
280 changed files with 4303 additions and 64256 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
-308
View File
@@ -1,308 +0,0 @@
name: E2E Tests
on:
push:
branches:
- dev
- master
pull_request:
branches:
- dev
- master
# BrowserStack runs are gated by the `e2e-browserstack` label or manual
# dispatch — see the e2e-browserstack job below. Local Chromium always runs.
workflow_dispatch:
inputs:
run-browserstack:
description: "Run BrowserStack suite"
type: boolean
default: true
env:
NODE_OPTIONS: --max_old_space_size=6144
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# ── Build the demo once and share it across test jobs via artifact ──────────
build-demo:
name: Build demo
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build demo
run: ./node_modules/.bin/gulp build-demo
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload demo build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: demo-dist
path: demo/dist/
if-no-files-found: error
retention-days: 3
# ── Build the e2e test app and share it via artifact ────────────────────────
build-e2e-test-app:
name: Build e2e test app
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build e2e test app
run: ./node_modules/.bin/gulp build-e2e-test-app
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload e2e test app build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
if-no-files-found: error
retention-days: 3
# ── Build the gallery and share it via artifact ─────────────────────────────
build-gallery:
name: Build gallery
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build gallery
run: ./node_modules/.bin/gulp build-gallery
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload gallery build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gallery-dist
path: gallery/dist/
if-no-files-found: error
retention-days: 3
# ── Run Playwright tests locally against Chromium ──────────────────────────
e2e-local:
name: E2E (local Chromium)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
# Fail fast if anything hangs. The whole suite should take < 15 minutes on
# Chromium; anything longer is almost certainly an install or webServer
# hang.
timeout-minutes: 30
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
# Cache the downloaded browser build keyed on the pinned Playwright
# version (yarn.lock), so re-runs skip the ~170 MB download.
- name: Cache Playwright browsers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('yarn.lock') }}
- name: Install Playwright browsers
run: yarn playwright install --with-deps chromium
timeout-minutes: 10
- name: Download demo build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright tests (local)
run: yarn test:e2e
timeout-minutes: 15
- name: Upload blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-local
path: test/e2e/reports/
retention-days: 3
# ── Run Playwright tests on BrowserStack (real devices + browsers) ─────────
# The BrowserStack SDK manages the Local tunnel and uploads results to the
# BrowserStack Automate dashboard automatically — no tunnel action needed.
#
# Gated on:
# - manual dispatch with the run-browserstack input enabled, OR
# - a PR with the `e2e-browserstack` label applied.
# This keeps CI fast on normal PRs while still allowing on-demand runs.
e2e-browserstack:
name: E2E (BrowserStack)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
if: |
(github.event_name == 'workflow_dispatch' && inputs.run-browserstack) ||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'e2e-browserstack'))
environment: browserstack
env:
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Download demo build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright tests (BrowserStack)
run: yarn test:e2e:browserstack
# ── Merge local blob reports and post PR comment ───────────────────────────
# Only depends on the local job — BrowserStack reports live on the
# BrowserStack Automate dashboard and don't feed into the local blob report.
report:
name: Report
needs: [e2e-local]
runs-on: ubuntu-latest
if: always()
permissions:
contents: read
pull-requests: write
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Download blob report (local)
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
continue-on-error: true
with:
name: blob-report-local
path: test/e2e/reports/
- name: Stage blobs for merge
run: node test/e2e/collect-blob-reports.mjs
- name: Merge blob reports
run: npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob
- name: Upload merged HTML report
id: upload-report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: test/e2e/reports/combined/
retention-days: 14
- name: Post report link to PR
if: github.event_name == 'pull_request'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = `## Playwright E2E test report\n\nThe combined HTML report is available as a workflow artifact.\n\n[View workflow run](${runUrl})`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
+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
-9
View File
@@ -54,16 +54,7 @@ src/cast/dev_const.ts
# test coverage
test/coverage/
# Playwright e2e output
test/e2e/reports/
test/e2e/test-results/
# E2E test app build output
test/e2e/app/dist/
# AI tooling
.claude
.cursor
.opencode
.serena
test/benchmarks/results/
-53
View File
@@ -1,53 +0,0 @@
# BrowserStack Automate configuration for Home Assistant frontend e2e tests.
# Credentials are read from BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY
# environment variables set in GitHub Actions (or locally).
# See: https://www.browserstack.com/docs/automate/playwright/getting-started/nodejs
userName: ${BROWSERSTACK_USERNAME}
accessKey: ${BROWSERSTACK_ACCESS_KEY}
projectName: Home Assistant Frontend
buildName: e2e tests
buildIdentifier: "CI #${BUILD_NUMBER}"
# ── Platforms ────────────────────────────────────────────────────────────────
platforms:
- os: Windows
osVersion: 11
browserName: chrome
browserVersion: latest
- os: OS X
osVersion: Ventura
browserName: playwright-firefox
browserVersion: latest
- deviceName: iPad 6th
osVersion: 12
browserName: playwright-webkit
- deviceName: iPhone 12
osVersion: 14
browserName: playwright-webkit
- deviceName: Samsung Galaxy S23
osVersion: 13
browserName: chrome
realMobile: true
parallelsPerPlatform: 1
# ── Local tunnel ─────────────────────────────────────────────────────────────
# The SDK manages the BrowserStack Local tunnel automatically.
browserstackLocal: true
framework: playwright
# Pin to the latest Playwright version BrowserStack supports. Our local
# @playwright/test is newer (1.59.x) which BrowserStack does not yet support,
# causing a "Malformed endpoint" connection error if left unset.
# Update this when BrowserStack adds support for a newer version.
# Supported versions: https://www.browserstack.com/docs/automate/playwright/browsers-and-os
playwrightVersion: 1.latest
# ── Debugging ────────────────────────────────────────────────────────────────
debug: false
networkLogs: false
consoleLogs: errors
testObservability: true
+1 -18
View File
@@ -1,3 +1,4 @@
/* global require, module, __dirname, process */
const path = require("path");
const env = require("./env.cjs");
const paths = require("./paths.cjs");
@@ -320,22 +321,4 @@ module.exports.config = {
isLandingPageBuild: true,
};
},
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
return {
name: "e2e-test-app" + nameSuffix(latestBuild),
entry: {
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
},
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
publicPath: publicPath(latestBuild),
defineOverlay: {
__VERSION__: JSON.stringify(`E2E-TEST-${env.version()}`),
__DEMO__: true,
},
isProdBuild,
latestBuild,
isStatsBuild,
};
},
};
-4
View File
@@ -1,13 +1,9 @@
// @ts-check
import globals from "globals";
import tseslint from "typescript-eslint";
import rootConfig from "../eslint.config.mjs";
export default tseslint.config(...rootConfig, {
languageOptions: {
globals: globals.node,
},
rules: {
"no-console": "off",
"import-x/no-extraneous-dependencies": "off",
@@ -1,3 +1,4 @@
/* global module */
// Browser-only replacement for core-js/internals/get-built-in-node-module.
// The original helper evaluates `Function('return require("...")')()`
// when it detects a Node environment, which causes a runtime
-7
View File
@@ -45,10 +45,3 @@ gulp.task(
])
)
);
gulp.task(
"clean-e2e-test-app",
gulp.parallel("clean-translations", async () =>
deleteSync([paths.e2eTestApp_output_root, paths.build_dir])
)
);
-41
View File
@@ -1,41 +0,0 @@
import gulp from "gulp";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./translations.js";
import "./rspack.js";
gulp.task(
"develop-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "development";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel(
"gen-icons-json",
"gen-pages-e2e-test-app-dev",
"build-translations",
"build-locale-data"
),
"copy-static-e2e-test-app",
"rspack-dev-server-e2e-test-app"
)
);
gulp.task(
"build-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-e2e-test-app",
"rspack-prod-e2e-test-app",
"gen-pages-e2e-test-app-prod"
)
);
+1 -21
View File
@@ -1,3 +1,4 @@
/* global process */
// Tasks to generate entry HTML
import {
@@ -267,24 +268,3 @@ gulp.task(
paths.landingPage_output_es5
)
);
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
gulp.task(
"gen-pages-e2e-test-app-dev",
genPagesDevTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root
)
);
gulp.task(
"gen-pages-e2e-test-app-prod",
genPagesProdTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root,
paths.e2eTestApp_output_latest
)
);
+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) {
-20
View File
@@ -201,23 +201,3 @@ gulp.task("copy-static-landing-page", async () => {
copyFonts(paths.landingPage_output_static);
copyTranslations(paths.landingPage_output_static);
});
gulp.task("copy-static-e2e-test-app", async () => {
// Copy app static files (icons, polyfills, etc.)
fs.copySync(
polyPath("public/static"),
path.resolve(paths.e2eTestApp_output_root, "static")
);
// Copy e2e test app public files (manifest, sw stubs)
const e2ePublic = path.resolve(paths.e2eTestApp_dir, "public");
if (fs.existsSync(e2ePublic)) {
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
}
copyPolyfills(paths.e2eTestApp_output_static);
copyMapPanel(paths.e2eTestApp_output_static);
copyFonts(paths.e2eTestApp_output_static);
copyTranslations(paths.e2eTestApp_output_static);
copyLocaleData(paths.e2eTestApp_output_static);
copyMdiIcons(paths.e2eTestApp_output_static);
});
@@ -1,40 +0,0 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process";
import gulp from "gulp";
import paths from "../paths.cjs";
const SOURCE_URL =
process.env.SENSOR_METADATA_URL ||
"https://raw.githubusercontent.com/home-assistant/core/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);
});
-2
View File
@@ -4,13 +4,11 @@ import "./clean.js";
import "./compress.js";
import "./demo.js";
import "./download-translations.js";
import "./e2e-test-app.js";
import "./entry-html.js";
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";
+29 -69
View File
@@ -1,6 +1,6 @@
// Gulp task to generate third-party license notices.
import { readFile, access, readdir } from "fs/promises";
import { readFile, access } from "fs/promises";
import { generateLicenseFile } from "generate-license-file";
import gulp from "gulp";
import path from "path";
@@ -11,98 +11,58 @@ const OUTPUT_FILE = path.join(
"third-party-licenses.txt"
);
const NODE_MODULES = path.resolve(paths.root_dir, "node_modules");
// The echarts package ships an Apache-2.0 NOTICE file that must be
// redistributed alongside the compiled output per Apache License §4(d).
const NOTICE_FILES = [path.join(NODE_MODULES, "echarts/NOTICE")];
const NOTICE_FILES = [
path.resolve(paths.root_dir, "node_modules/echarts/NOTICE"),
];
// Some packages need a manual license override (e.g. they ship multiple
// license files and we must pick the right one for the bundled code).
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
//
// Each entry is pinned to a specific version. If a package is updated,
// this list must be reviewed and the version updated after verifying
// that the new version's license still matches. The build will fail if
// the pinned version is no longer installed.
// that the new version's license still matches. The build will fail
// if the installed version does not match the pinned version.
const LICENSE_OVERRIDES = [
{
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
packageName: "type-fest",
version: "5.7.0",
licenseFile: "license-mit",
licensePath: path.resolve(
paths.root_dir,
"node_modules/type-fest/license-mit"
),
},
];
// Locate the directory of an installed package matching an exact version.
//
// The copy we care about may be hoisted to the top-level node_modules or
// nested under a dependency when a different version occupies the hoisted
// slot (e.g. a build-only dependency pulling in an older release). Searching
// both keeps this check independent of yarn's hoisting decisions, which can
// shift when unrelated dependencies are added.
async function findPackageDir(packageName, version) {
const candidateDirs = [path.join(NODE_MODULES, packageName)];
// Collect one level of nesting: node_modules/<dep>/node_modules/<pkg> and
// node_modules/@scope/<dep>/node_modules/<pkg>.
let topLevel = [];
try {
topLevel = await readdir(NODE_MODULES, { withFileTypes: true });
} catch {
// node_modules unreadable — fall back to the hoisted candidate only.
}
for (const entry of topLevel) {
if (!entry.isDirectory() || entry.name === packageName) {
continue;
}
if (entry.name.startsWith("@")) {
const scopeDir = path.join(NODE_MODULES, entry.name);
// eslint-disable-next-line no-await-in-loop
const scoped = await readdir(scopeDir, { withFileTypes: true }).catch(
() => []
);
for (const dep of scoped) {
if (dep.isDirectory()) {
candidateDirs.push(
path.join(scopeDir, dep.name, "node_modules", packageName)
);
}
}
} else {
candidateDirs.push(
path.join(NODE_MODULES, entry.name, "node_modules", packageName)
);
}
}
for (const dir of candidateDirs) {
// eslint-disable-next-line no-await-in-loop
const pkg = await readFile(path.join(dir, "package.json"), "utf-8")
.then(JSON.parse)
.catch(() => null);
if (pkg?.version === version) {
return dir;
}
}
return null;
}
gulp.task("gen-licenses", async () => {
const licenseOverrides = {};
for (const { packageName, version, licenseFile } of LICENSE_OVERRIDES) {
// eslint-disable-next-line no-await-in-loop
const packageDir = await findPackageDir(packageName, version);
for (const { packageName, version, licensePath } of LICENSE_OVERRIDES) {
const pkgJsonPath = path.resolve(
paths.root_dir,
`node_modules/${packageName}/package.json`
);
if (!packageDir) {
let packageJSON;
try {
// eslint-disable-next-line no-await-in-loop
packageJSON = JSON.parse(await readFile(pkgJsonPath, "utf-8"));
} catch {
throw new Error(
`License override for "${packageName}" is pinned to version ${version}, but that version is not installed. ` +
`package.json for "${packageName}" not found or unreadable at ${pkgJsonPath}`
);
}
if (packageJSON.version !== version) {
throw new Error(
`License override for "${packageName}" is pinned to version ${version}, but found version ${packageJSON.version}. ` +
`Please verify the new version's license and update the override in build-scripts/gulp/licenses.js.`
);
}
const licensePath = path.join(packageDir, licenseFile);
try {
// eslint-disable-next-line no-await-in-loop
await access(licensePath);
-20
View File
@@ -14,7 +14,6 @@ import {
createDemoConfig,
createGalleryConfig,
createLandingPageConfig,
createE2eTestAppConfig,
} from "../rspack.cjs";
const bothBuilds = (createConfigFunc, params) => [
@@ -232,22 +231,3 @@ gulp.task("rspack-prod-landing-page", () =>
})
)
);
gulp.task("rspack-dev-server-e2e-test-app", () =>
runDevServer({
compiler: rspack(
createE2eTestAppConfig({ isProdBuild: false, latestBuild: true })
),
contentBase: paths.e2eTestApp_output_root,
port: 8095,
})
);
gulp.task("rspack-prod-e2e-test-app", () =>
prodBuild(
bothBuilds(createE2eTestAppConfig, {
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
})
)
);
-11
View File
@@ -50,15 +50,4 @@ module.exports = {
),
translations_src: path.resolve(__dirname, "../src/translations"),
e2eTestApp_dir: path.resolve(__dirname, "../test/e2e/app"),
e2eTestApp_output_root: path.resolve(__dirname, "../test/e2e/app/dist"),
e2eTestApp_output_static: path.resolve(
__dirname,
"../test/e2e/app/dist/static"
),
e2eTestApp_output_latest: path.resolve(
__dirname,
"../test/e2e/app/dist/frontend_latest"
),
};
+1 -6
View File
@@ -1,3 +1,4 @@
/* global require, module, __dirname */
const { existsSync } = require("fs");
const path = require("path");
const rspack = require("@rspack/core");
@@ -337,11 +338,6 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
createRspackConfig(
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
);
module.exports = {
createAppConfig,
createDemoConfig,
@@ -349,5 +345,4 @@ module.exports = {
createGalleryConfig,
createRspackConfig,
createLandingPageConfig,
createE2eTestAppConfig,
};
+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: {} } },
}));
};
-21
View File
@@ -1,21 +0,0 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockAssist = (hass: MockHomeAssistant) => {
// Stub for assist pipeline list — returns empty so developer tools assist
// tab loads without errors.
hass.mockWS("assist_pipeline/pipeline/list", () => ({
pipelines: [],
preferred_pipeline: null,
}));
// Stub for assist pipeline run — immediately sends run-end event so
// the UI does not hang waiting for a response.
hass.mockWS("assist_pipeline/run", (_msg, _hass, onChange) => {
if (onChange) {
onChange({
type: "run-end",
});
}
return null;
});
};
-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);
};
-5
View File
@@ -1,5 +0,0 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockUpdate = (hass: MockHomeAssistant) => {
hass.mockWS("update/list", () => []);
};
-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);
};
-6
View File
@@ -234,12 +234,6 @@ export default tseslint.config(
globals: globals.serviceworker,
},
},
{
files: ["test/e2e/*.mjs"],
languageOptions: {
globals: globals.node,
},
},
{
plugins: {
html,
-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",
+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);
+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!
+1
View File
@@ -372,6 +372,7 @@ export class DemoEntityState extends LitElement {
hass.localize,
entry.stateObj,
hass.locale,
[], // numericDeviceClasses
hass.config,
hass.entities
)}`,
+7 -20
View File
@@ -21,17 +21,7 @@
"prepack": "pinst --disable",
"postpack": "pinst --enable",
"test": "vitest run --config test/vitest.config.ts",
"test:bench": "vitest bench --run --config test/vitest.bench.config.ts",
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
"test:e2e": "node test/e2e/run-suites.mjs demo app gallery",
"test:e2e:browserstack": "node test/e2e/run-suites.mjs demo:browserstack app:browserstack gallery:browserstack",
"test:e2e:show-report": "yarn playwright show-report test/e2e/reports/combined",
"test:e2e:demo": "playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:demo:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:app": "playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:app:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts",
"test:e2e:gallery:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.gallery.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",
@@ -50,7 +40,7 @@
"@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.14",
"@formatjs/intl-getcanonicallocales": "3.2.10",
@@ -141,16 +131,14 @@
"@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",
"@playwright/test": "1.60.0",
"@rsdoctor/rspack-plugin": "1.5.13",
"@rspack/core": "2.0.8",
"@rsdoctor/rspack-plugin": "1.5.12",
"@rspack/core": "2.0.6",
"@rspack/dev-server": "2.0.3",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
@@ -169,9 +157,8 @@
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
"browserstack-node-sdk": "1.53.2",
"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",
@@ -199,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",
-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
+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";
+46 -51
View File
@@ -17,51 +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";
// Domains whose state is a timezone-agnostic date and/or time string.
const DATE_TIME_DOMAINS = new Set(["date", "input_datetime", "time"]);
// Domains whose state is a timestamp.
const TIMESTAMP_DOMAINS = new Set([
"ai_task",
"button",
"conversation",
"event",
"image",
"infrared",
"input_button",
"notify",
"radio_frequency",
"scene",
"stt",
"tag",
"tts",
"wake_word",
"datetime",
]);
// Maps Intl.NumberFormat part types to ValuePart types for monetary states.
const MONETARY_TYPE_MAP: Record<string, ValuePart["type"]> = {
integer: "value",
group: "value",
decimal: "value",
fraction: "value",
minusSign: "value",
plusSign: "value",
literal: "literal",
currency: "unit",
};
const NUMERICAL_DOMAINS = ["counter", "input_number", "number"];
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
@@ -72,6 +34,7 @@ export const computeStateDisplay = (
return computeStateDisplayFromEntityAttributes(
localize,
locale,
sensorNumericDeviceClasses,
config,
entity,
stateObj.entity_id,
@@ -83,6 +46,7 @@ export const computeStateDisplay = (
export const computeStateDisplayFromEntityAttributes = (
localize: LocalizeFunc,
locale: FrontendLocaleData,
sensorNumericDeviceClasses: string[],
config: HassConfig,
entity: EntityRegistryDisplayEntry | undefined,
entityId: string,
@@ -92,6 +56,7 @@ export const computeStateDisplayFromEntityAttributes = (
const parts = computeStateToPartsFromEntityAttributes(
localize,
locale,
sensorNumericDeviceClasses,
config,
entity,
entityId,
@@ -104,6 +69,7 @@ export const computeStateDisplayFromEntityAttributes = (
const computeStateToPartsFromEntityAttributes = (
localize: LocalizeFunc,
locale: FrontendLocaleData,
sensorNumericDeviceClasses: string[],
config: HassConfig,
entity: EntityRegistryDisplayEntry | undefined,
entityId: string,
@@ -120,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 (
@@ -172,10 +138,21 @@ 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 (e.g. "-" + "12" + "." + "00" → "-12.00")
@@ -214,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`.
@@ -273,7 +250,23 @@ const computeStateToPartsFromEntityAttributes = (
// state is a timestamp
if (
TIMESTAMP_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))
) {
@@ -314,6 +307,7 @@ export const computeStateToParts = (
localize: LocalizeFunc,
stateObj: HassEntity,
locale: FrontendLocaleData,
sensorNumericDeviceClasses: string[],
config: HassConfig,
entities: HomeAssistant["entities"],
state?: string
@@ -324,6 +318,7 @@ export const computeStateToParts = (
return computeStateToPartsFromEntityAttributes(
localize,
locale,
sensorNumericDeviceClasses,
config,
entity,
stateObj.entity_id,
+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";
};
+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,
+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 -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);
},
});
@@ -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;
+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 -33
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,32 +420,445 @@ 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) => {
@@ -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 -26
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)) {
+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;
-1
View File
@@ -183,7 +183,6 @@ export class HaControlSelectMenu extends LitElement {
gap: 10px;
width: 100%;
user-select: none;
font-family: var(--ha-font-family-body, inherit);
font-style: normal;
font-weight: var(--ha-font-weight-normal);
letter-spacing: 0.25px;
+5 -21
View File
@@ -12,20 +12,6 @@ import type {
HaFormSelectSchema,
} from "./types";
/**
* The underlying select returns option values as strings. Map a selected value
* back to its original option value so the source type is retained (for example
* a number coming from a backend `vol.In` schema), falling back to the value
* itself when no option matches.
*/
export const matchSelectOptionValue = (
options: HaFormSelectSchema["options"],
value: string
): HaFormSelectData => {
const option = options.find((opt) => String(opt[0]) === String(value));
return option ? option[0] : value;
};
@customElement("ha-form-select")
export class HaFormSelect extends LitElement implements HaFormElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -80,18 +66,16 @@ export class HaFormSelect extends LitElement implements HaFormElement {
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
let value: HaFormSelectData | undefined = ev.detail.value;
if (value === "") {
value = undefined;
} else if (value != null) {
value = matchSelectOptionValue(this.schema.options, value);
}
let value: string | undefined = ev.detail.value;
if (value === this.data) {
return;
}
if (value === "") {
value = undefined;
}
fireEvent(this, "value-changed", {
value,
});
-2
View File
@@ -41,8 +41,6 @@ const CUSTOM_ICONS: Record<string, () => Promise<string>> = {
),
esphome: () =>
import("../resources/esphome-logo-svg").then((mod) => mod.mdiEsphomeLogo),
matter: () =>
import("../resources/matter-logo-svg").then((mod) => mod.mdiMatterLogo),
};
@customElement("ha-icon")
+9 -3
View File
@@ -101,9 +101,15 @@ export class HaLabelsPicker extends LitElement {
language: string
) =>
value
?.map((id) => labels?.find((label) => label.label_id === id))
.filter((label): label is LabelRegistryEntry => label !== undefined)
.sort((a, b) => stringCompare(a.name, b.name, language))
?.map(
(id) =>
labels?.find((label) => label.label_id === id) || {
label_id: id,
name: id,
color: "rgba(var(--rgb-primary-text-color), 0.15)",
}
)
.sort((a, b) => stringCompare(a?.name || "", b?.name || "", language))
.map((label) => ({
...label,
style: getLabelColorStyle(label.color),
@@ -354,9 +354,7 @@ export class HaSerialPortSelector extends LitElement {
}
private get _selectorDomain(): string | undefined {
// `domain` is the integration domain even in options flows, where the flow
// handler is the config entry id instead.
return this.context?.domain;
return this.context?.handler;
}
private _memoRecommendedDomains = memoizeOne(
+22 -22
View File
@@ -3,7 +3,6 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { goBack } from "../common/navigate";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-icon-button-arrow-prev";
import "./ha-menu-button";
@@ -12,9 +11,6 @@ const PASSIVE_EVENT_OPTIONS = { passive: true } as const;
export const haTopAppBarFixedStyles = css`
:host {
display: block;
position: relative;
height: 100vh;
overflow: hidden;
--total-top-app-bar-height: calc(
var(--header-height, 0px) + var(--sub-row-height, 0px)
);
@@ -25,11 +21,10 @@ export const haTopAppBarFixedStyles = css`
box-sizing: border-box;
color: var(--app-header-text-color, #fff);
background-color: var(--app-header-background-color, var(--primary-color));
position: absolute;
position: fixed;
top: 0;
inset-inline-start: 0;
inset-inline-end: 0;
width: 100%;
width: var(--ha-top-app-bar-width, 100%);
z-index: 4;
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
@@ -121,17 +116,17 @@ export const haTopAppBarFixedStyles = css`
}
.top-app-bar-fixed-adjust {
box-sizing: border-box;
position: absolute;
top: calc(
height: calc(
100vh - var(--total-top-app-bar-height, 0px) - var(
--safe-area-inset-top,
0px
) - var(--safe-area-inset-bottom, 0px)
);
padding-top: calc(
var(--total-top-app-bar-height, 0px) + var(--safe-area-inset-top, 0px)
);
bottom: 0;
inset-inline-start: 0;
inset-inline-end: 0;
padding-bottom: var(--safe-area-inset-bottom);
padding-right: var(--safe-area-inset-right);
overflow: auto;
}
:host([narrow]) .top-app-bar-fixed-adjust {
@@ -151,8 +146,6 @@ export class HaTopAppBarFixed extends LitElement {
@query(".sub-row") protected _subRowElement?: HTMLElement;
@query(".top-app-bar-fixed-adjust") protected _scrollElement?: HTMLElement;
@state() private _hasSubRow = false;
private _scrollTarget?: HTMLElement | Window;
@@ -161,13 +154,14 @@ export class HaTopAppBarFixed extends LitElement {
@property({ attribute: false })
public get scrollTarget(): HTMLElement | Window {
return this._scrollTarget || this._scrollElement || window;
return this._scrollTarget || window;
}
public set scrollTarget(value: HTMLElement | Window) {
const old = this.scrollTarget;
this._unregisterListeners();
this._scrollTarget = value;
this._updateBarPosition();
this.requestUpdate("scrollTarget", old);
if (this.isConnected) {
this._registerListeners();
@@ -189,6 +183,7 @@ export class HaTopAppBarFixed extends LitElement {
if (this.hasUpdated) {
this._observeSubRowHeight();
this._updateSubRowHeight();
this._updateBarPosition();
this._registerListeners();
this._syncScrollState();
}
@@ -248,7 +243,7 @@ export class HaTopAppBarFixed extends LitElement {
}
protected _renderContent() {
return html`<div class="top-app-bar-fixed-adjust ha-scrollbar">
return html`<div class="top-app-bar-fixed-adjust">
<slot></slot>
</div>`;
}
@@ -257,6 +252,7 @@ export class HaTopAppBarFixed extends LitElement {
super.firstUpdated(changedProperties);
this._observeSubRowHeight();
this._updateSubRowHeight();
this._updateBarPosition();
this._registerListeners();
this._syncScrollState();
}
@@ -274,6 +270,13 @@ export class HaTopAppBarFixed extends LitElement {
this._unregisterListeners();
}
protected _updateBarPosition() {
if (this._barElement) {
this._barElement.style.position =
this.scrollTarget === window ? "" : "absolute";
}
}
protected _syncScrollState = () => {
const scrollTop =
this.scrollTarget instanceof Window
@@ -333,10 +336,7 @@ export class HaTopAppBarFixed extends LitElement {
this.style.setProperty("--sub-row-height", `${subRowHeight}px`);
};
static override styles: CSSResultGroup = [
haStyleScrollbar,
haTopAppBarFixedStyles,
];
static override styles: CSSResultGroup = haTopAppBarFixedStyles;
}
declare global {
+6 -16
View File
@@ -85,25 +85,15 @@ export class HaTTSVoicePicker extends LitElement {
await listTTSVoices(this.hass, this.engineId, this.language)
).voices;
const valueIsValid =
this.value &&
this._voices?.some((voice) => voice.voice_id === this.value);
if (valueIsValid) {
if (!this.value) {
return;
}
// The current value is missing or no longer valid for the loaded voices.
// When a voice is required, auto-select the first one (the <ha-select>
// already displays it) so the value is propagated to the parent;
// otherwise clear it.
const newValue =
this.required && this._voices?.length
? this._voices[0].voice_id
: undefined;
if (newValue !== this.value) {
this.value = newValue;
if (
!this._voices ||
!this._voices.find((voice) => voice.voice_id === this.value)
) {
this.value = undefined;
fireEvent(this, "value-changed", { value: this.value });
}
}
@@ -29,7 +29,6 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
<div
class=${classMap({
"top-app-bar-fixed-adjust": true,
"ha-scrollbar": true,
"top-app-bar-fixed-adjust--pane": this.pane,
})}
>
@@ -131,7 +130,12 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
.top-app-bar-fixed-adjust--pane {
display: flex;
overflow: hidden;
height: calc(
100vh - var(--total-top-app-bar-height, 0px) - var(
--safe-area-inset-top,
0px
) - var(--safe-area-inset-bottom, 0px)
);
}
.pane {
@@ -163,7 +167,6 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
position: relative;
flex: 1;
height: 100%;
min-width: 0;
}
.top-app-bar-fixed-adjust--pane .content {
+13
View File
@@ -0,0 +1,13 @@
import timezones from "google-timezones-json";
export const createTimezoneListEl = () => {
const list = document.createElement("datalist");
list.id = "timezones";
Object.keys(timezones).forEach((key) => {
const option = document.createElement("option");
option.value = key;
option.innerText = timezones[key];
list.appendChild(option);
});
return list;
};
+1 -1
View File
@@ -181,7 +181,7 @@ export interface RestoreBackupParams {
restore_homeassistant?: boolean;
}
export const fetchBackupConfig = (hass: Pick<HomeAssistant, "callWS">) =>
export const fetchBackupConfig = (hass: HomeAssistant) =>
hass.callWS<{ config: BackupConfig }>({ type: "backup/config/info" });
export const updateBackupConfig = (
+16 -20
View File
@@ -1121,12 +1121,14 @@ const getSummedDataPartial = (
const timestamps = new Set<number>();
Object.entries(statIds).forEach(([key, subStatIds]) => {
const totalStats: Record<number, number> = {};
const sets: Record<string, Record<number, number>> = {};
let sum = 0;
subStatIds!.forEach((id) => {
const stats = compare ? data.statsCompare[id] : data.stats[id];
if (!stats) {
return;
}
const set = {};
stats.forEach((stat) => {
if (stat.change === null || stat.change === undefined) {
return;
@@ -1137,6 +1139,7 @@ const getSummedDataPartial = (
stat.start in totalStats ? totalStats[stat.start] + val : val;
timestamps.add(stat.start);
});
sets[id] = set;
});
summedData[key] = totalStats;
summedData.total[key] = sum;
@@ -1187,13 +1190,6 @@ const computeConsumptionDataPartial = (
},
};
const fromGrid = data.from_grid;
const toGrid = data.to_grid;
const solarData = data.solar;
const toBattery = data.to_battery;
const fromBattery = data.from_battery;
const total = outData.total;
data.timestamps.forEach((t) => {
const {
grid_to_battery,
@@ -1205,29 +1201,29 @@ const computeConsumptionDataPartial = (
solar_to_battery,
solar_to_grid,
} = computeConsumptionSingle({
from_grid: fromGrid && (fromGrid[t] ?? 0),
to_grid: toGrid && (toGrid[t] ?? 0),
solar: solarData && (solarData[t] ?? 0),
to_battery: toBattery && (toBattery[t] ?? 0),
from_battery: fromBattery && (fromBattery[t] ?? 0),
from_grid: data.from_grid && (data.from_grid[t] ?? 0),
to_grid: data.to_grid && (data.to_grid[t] ?? 0),
solar: data.solar && (data.solar[t] ?? 0),
to_battery: data.to_battery && (data.to_battery[t] ?? 0),
from_battery: data.from_battery && (data.from_battery[t] ?? 0),
});
outData.used_total[t] = used_total;
total.used_total += used_total;
outData.total.used_total += used_total;
outData.grid_to_battery[t] = grid_to_battery;
total.grid_to_battery += grid_to_battery;
outData.total.grid_to_battery += grid_to_battery;
outData.battery_to_grid![t] = battery_to_grid;
total.battery_to_grid += battery_to_grid;
outData.total.battery_to_grid += battery_to_grid;
outData.used_battery![t] = used_battery;
total.used_battery += used_battery;
outData.total.used_battery += used_battery;
outData.used_grid![t] = used_grid;
total.used_grid += used_grid;
outData.total.used_grid += used_grid;
outData.used_solar![t] = used_solar;
total.used_solar += used_solar;
outData.total.used_solar += used_solar;
outData.solar_to_battery[t] = solar_to_battery;
total.solar_to_battery += solar_to_battery;
outData.total.solar_to_battery += solar_to_battery;
outData.solar_to_grid[t] = solar_to_grid;
total.solar_to_grid += solar_to_grid;
outData.total.solar_to_grid += solar_to_grid;
});
return outData;
+10 -24
View File
@@ -73,44 +73,30 @@ export const getEntities = (
let entityIds = Object.keys(hass.states);
// These run over every entity, so use Sets for O(1) membership instead of
// repeated Array.includes scans.
if (includeEntities) {
const includeEntitiesSet = new Set(includeEntities);
entityIds = entityIds.filter((entityId) =>
includeEntitiesSet.has(entityId)
includeEntities.includes(entityId)
);
}
if (excludeEntities) {
const excludeEntitiesSet = new Set(excludeEntities);
entityIds = entityIds.filter(
(entityId) => !excludeEntitiesSet.has(entityId)
(entityId) => !excludeEntities.includes(entityId)
);
}
if (includeDomains) {
const includeDomainsSet = new Set(includeDomains);
entityIds = entityIds.filter((eid) =>
includeDomainsSet.has(computeDomain(eid))
includeDomains.includes(computeDomain(eid))
);
}
if (excludeDomains) {
const excludeDomainsSet = new Set(excludeDomains);
entityIds = entityIds.filter(
(eid) => !excludeDomainsSet.has(computeDomain(eid))
(eid) => !excludeDomains.includes(computeDomain(eid))
);
}
// These values are the same for every entity, so compute them once instead
// of inside the map over (potentially thousands of) entities.
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const domainNames = new Map<string, string>();
items = entityIds.map<EntityComboBoxItem>((entityId) => {
const stateObj = hass.states[entityId];
@@ -124,12 +110,12 @@ export const getEntities = (
hass.floors
);
const domain = computeDomain(entityId);
let domainName = domainNames.get(domain);
if (domainName === undefined) {
domainName = domainToName(hass.localize, domain);
domainNames.set(domain, domainName);
}
const domainName = domainToName(hass.localize, computeDomain(entityId));
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
+3 -4
View File
@@ -7,12 +7,11 @@ interface EntitySource {
export type EntitySources = Record<string, EntitySource>;
const fetchEntitySources = (
hass: Pick<HomeAssistant, "callWS">
): Promise<EntitySources> => hass.callWS({ type: "entity/source" });
const fetchEntitySources = (hass: HomeAssistant): Promise<EntitySources> =>
hass.callWS({ type: "entity/source" });
export const fetchEntitySourcesWithCache = (
hass: Pick<HomeAssistant, "callWS" | "states">
hass: HomeAssistant
): Promise<EntitySources> =>
timeCachePromiseFunc(
"_entitySources",
+13
View File
@@ -44,6 +44,15 @@ interface AddonTranslations {
configuration?: Record<string, AddonFieldTranslation>;
}
export interface AddonNetworkIsolationParams {
interface: string;
ipv4: string;
}
export interface AddonNetworkIsolation extends AddonNetworkIsolationParams {
driver: "macvlan";
}
export interface HassioAddonInfo {
advanced: boolean;
available: boolean;
@@ -100,6 +109,9 @@ export interface HassioAddonDetails extends HassioAddonInfo {
long_description: null | string;
machine: any;
network_description: null | Record<string, string>;
network_isolation: AddonNetworkIsolation | null;
network_isolation_available: boolean;
network_isolation_mac: string | null;
network: null | Record<string, number>;
options: Record<string, unknown>;
privileged: any;
@@ -143,6 +155,7 @@ export interface HassioAddonSetOptionParams {
auto_update?: boolean;
ingress_panel?: boolean;
network?: Record<string, unknown> | null;
network_isolation?: AddonNetworkIsolationParams | null;
watchdog?: boolean;
}
+1
View File
@@ -19,6 +19,7 @@ export interface NetworkInterface {
ipv6?: Partial<IpConfiguration>;
type: "ethernet" | "wireless" | "vlan";
wifi?: Partial<WifiConfiguration> | null;
network_isolation_capable?: boolean;
}
export interface DockerNetwork {
+70 -70
View File
@@ -10,7 +10,6 @@ import { computeStateDisplayFromEntityAttributes } from "../common/entity/comput
import { computeStateNameFromEntityAttributes } from "../common/entity/compute_state_name";
import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types";
import { isNumericSensorDeviceClass } from "./sensor";
import type { FrontendLocaleData } from "./translation";
import type { Statistics } from "./recorder";
@@ -165,70 +164,60 @@ export class HistoryStream {
? (new Date().getTime() - 60 * 60 * this.hoursToShow * 1000) / 1000
: undefined;
const newHistory: HistoryStates = {};
// Build the union of entity ids (existing first, then new ones) in a
// single pass and process each entity inline. The per-entity slot is
// always assigned below before being read, so there is no need to
// pre-seed every key with an empty array first.
const streamStates = streamMessage.states;
const processEntity = (entityId: string) => {
const inCombined = entityId in this.combinedHistory;
const inStream = entityId in streamStates;
if (inCombined && inStream) {
for (const entityId of Object.keys(this.combinedHistory)) {
newHistory[entityId] = [];
}
for (const entityId of Object.keys(streamMessage.states)) {
newHistory[entityId] = [];
}
for (const entityId of Object.keys(newHistory)) {
if (
entityId in this.combinedHistory &&
entityId in streamMessage.states
) {
const entityCombinedHistory = this.combinedHistory[entityId];
const lastEntityCombinedHistory =
entityCombinedHistory[entityCombinedHistory.length - 1];
newHistory[entityId] = entityCombinedHistory.concat(
streamStates[entityId]
streamMessage.states[entityId]
);
if (streamStates[entityId][0].lu < lastEntityCombinedHistory.lu) {
if (
streamMessage.states[entityId][0].lu < lastEntityCombinedHistory.lu
) {
// If the history is out of order we have to sort it.
newHistory[entityId] = newHistory[entityId].sort(
(a, b) => a.lu - b.lu
);
}
} else if (inCombined) {
} else if (entityId in this.combinedHistory) {
newHistory[entityId] = this.combinedHistory[entityId];
} else {
newHistory[entityId] = streamStates[entityId];
return;
newHistory[entityId] = streamMessage.states[entityId];
}
// Remove old history (only entities present in combinedHistory reach
// here without an early return).
if (purgeBeforePythonTime) {
// Single pass: split into kept (lu >= cutoff, preserving order) and
// track the last expired state (lu < cutoff) without allocating a
// second array.
const states = newHistory[entityId];
const kept: EntityHistoryState[] = [];
let lastExpiredState: EntityHistoryState | undefined;
for (const state of states) {
if (state.lu < purgeBeforePythonTime) {
lastExpiredState = state;
} else {
kept.push(state);
}
// Remove old history
if (purgeBeforePythonTime && entityId in this.combinedHistory) {
const expiredStates = newHistory[entityId].filter(
(state) => state.lu < purgeBeforePythonTime
);
if (!expiredStates.length) {
continue;
}
if (!lastExpiredState) {
return;
}
newHistory[entityId] = kept;
if (kept.length && kept[0].lu === purgeBeforePythonTime) {
return;
newHistory[entityId] = newHistory[entityId].filter(
(state) => state.lu >= purgeBeforePythonTime
);
if (
newHistory[entityId].length &&
newHistory[entityId][0].lu === purgeBeforePythonTime
) {
continue;
}
// Update the first entry to the start time state
// as we need to preserve the start time state and
// only expire the rest of the history as it ages.
const lastExpiredState = expiredStates[expiredStates.length - 1];
lastExpiredState.lu = purgeBeforePythonTime;
delete lastExpiredState.lc;
kept.unshift(lastExpiredState);
}
};
for (const entityId of Object.keys(this.combinedHistory)) {
processEntity(entityId);
}
for (const entityId of Object.keys(streamStates)) {
if (!(entityId in this.combinedHistory)) {
processEntity(entityId);
newHistory[entityId].unshift(lastExpiredState);
}
}
this.combinedHistory = newHistory;
@@ -357,6 +346,7 @@ const processTimelineEntity = (
state_localize: computeStateDisplayFromEntityAttributes(
localize,
locale,
[], // numeric device classes not used for Timeline
config,
entities[entityId],
entityId,
@@ -391,18 +381,16 @@ const processLineChartEntities = (
): LineChartUnit => {
const data: LineChartEntity[] = [];
const entityIds = Object.keys(entities);
entityIds.forEach((entityId) => {
Object.keys(entities).forEach((entityId) => {
const states = entities[entityId];
const first: EntityHistoryState = states[0];
const domain = computeDomain(entityId);
const useLastUpdated = DOMAINS_USE_LAST_UPDATED.includes(domain);
const processedStates: LineChartState[] = [];
for (const state of states) {
let processedState: LineChartState;
if (useLastUpdated) {
if (DOMAINS_USE_LAST_UPDATED.includes(domain)) {
processedState = {
state: state.s,
last_changed: state.lu * 1000,
@@ -424,11 +412,13 @@ const processLineChartEntities = (
};
}
const len = processedStates.length;
if (
len > 1 &&
equalState(processedState, processedStates[len - 1]) &&
equalState(processedState, processedStates[len - 2])
processedStates.length > 1 &&
equalState(
processedState,
processedStates[processedStates.length - 1]
) &&
equalState(processedState, processedStates[processedStates.length - 2])
) {
continue;
}
@@ -454,17 +444,11 @@ const processLineChartEntities = (
return {
unit,
device_class,
identifier: entityIds.join(""),
identifier: Object.keys(entities).join(""),
data,
};
};
const SPECIAL_DOMAIN_CLASSES: Record<string, string | undefined> = {
climate: "temperature",
humidifier: "humidity",
water_heater: "temperature",
};
const NUMERICAL_DOMAINS = ["counter", "input_number", "number"];
const isNumericFromDomain = (domain: string) =>
@@ -473,12 +457,20 @@ const isNumericFromDomain = (domain: string) =>
const isNumericFromAttributes = (attributes: Record<string, any>) =>
"unit_of_measurement" in attributes || "state_class" in attributes;
const isNumericSensorEntity = (
stateObj: HassEntity,
sensorNumericalDeviceClasses: string[]
) =>
stateObj.attributes.device_class != null &&
sensorNumericalDeviceClasses.includes(stateObj.attributes.device_class);
const BLANK_UNIT = " ";
export const convertStatisticsToHistory = (
hass: HomeAssistant,
statistics: Statistics,
statisticIds: string[],
sensorNumericDeviceClasses: string[],
splitDeviceClasses = false
): HistoryResult => {
// Maintain the statistic id ordering
@@ -506,6 +498,7 @@ export const convertStatisticsToHistory = (
statsHistoryStates,
[],
hass.localize,
sensorNumericDeviceClasses,
splitDeviceClasses,
true
);
@@ -535,6 +528,7 @@ export const computeHistory = (
stateHistory: HistoryStates,
entityIds: string[],
localize: LocalizeFunc,
sensorNumericalDeviceClasses: string[],
splitDeviceClasses = false,
forceNumeric = false
): HistoryResult => {
@@ -581,6 +575,7 @@ export const computeHistory = (
domain,
currentState,
numericStateFromHistory,
sensorNumericalDeviceClasses,
forceNumeric
);
@@ -598,8 +593,14 @@ export const computeHistory = (
}[domain];
}
const specialDomainClasses = {
climate: "temperature",
humidifier: "humidity",
water_heater: "temperature",
};
const deviceClass: string | undefined =
SPECIAL_DOMAIN_CLASSES[domain] ||
specialDomainClasses[domain] ||
(currentState?.attributes || numericStateFromHistory?.a)?.device_class;
const key = computeGroupKey(unit, deviceClass, splitDeviceClasses);
@@ -655,6 +656,7 @@ export const isNumericEntity = (
domain: string,
currentState: HassEntity | undefined,
numericStateFromHistory: EntityHistoryState | undefined,
sensorNumericalDeviceClasses: string[],
forceNumeric = false
): boolean =>
forceNumeric ||
@@ -662,7 +664,7 @@ export const isNumericEntity = (
(currentState != null && isNumericFromAttributes(currentState.attributes)) ||
(currentState != null &&
domain === "sensor" &&
isNumericSensorDeviceClass(currentState.attributes.device_class)) ||
isNumericSensorEntity(currentState, sensorNumericalDeviceClasses)) ||
numericStateFromHistory != null;
export const mergeHistoryResults = (
@@ -723,18 +725,16 @@ export const mergeHistoryResults = (
}
const newLineItem: LineChartUnit = { ...historyItem, data: [] };
const historyDataByEntity = new Map(
historyItem.data.map((d) => [d.entity_id, d])
);
const ltsDataByEntity = new Map(ltsItem.data.map((d) => [d.entity_id, d]));
const entities = new Set([
...historyDataByEntity.keys(),
...ltsDataByEntity.keys(),
...historyItem.data.map((d) => d.entity_id),
...ltsItem.data.map((d) => d.entity_id),
]);
for (const entity of entities) {
const historyDataItem = historyDataByEntity.get(entity);
const ltsDataItem = ltsDataByEntity.get(entity);
const historyDataItem = historyItem.data.find(
(d) => d.entity_id === entity
);
const ltsDataItem = ltsItem.data.find((d) => d.entity_id === entity);
if (!historyDataItem || !ltsDataItem) {
newLineItem.data.push(historyDataItem || ltsDataItem!);
+7
View File
@@ -43,6 +43,11 @@ export const lightSupportsColorMode = (
mode: LightColorMode
) => entity.attributes.supported_color_modes?.includes(mode) || false;
export const lightIsInColorMode = (entity: LightEntity) =>
(entity.attributes.color_mode &&
modesSupportingColor.includes(entity.attributes.color_mode)) ||
false;
export const lightSupportsColor = (entity: LightEntity) =>
entity.attributes.supported_color_modes?.some((mode) =>
modesSupportingColor.includes(mode)
@@ -154,3 +159,5 @@ export const computeDefaultFavoriteColors = (
return colors;
};
export const formatTempColor = (value: number) => `${value} K`;
+6 -2
View File
@@ -369,10 +369,14 @@ export const localizeStateMessage = (
});
};
export const filterLogbookCompatibleEntities = (entity) => {
export const filterLogbookCompatibleEntities = (
entity,
sensorNumericDeviceClasses: string[] = []
) => {
const domain = computeStateDomain(entity);
const continuous =
CONTINUOUS_DOMAINS.includes(domain) ||
(domain === "sensor" && isNumericEntity(domain, entity, undefined));
(domain === "sensor" &&
isNumericEntity(domain, entity, undefined, sensorNumericDeviceClasses));
return !continuous;
};
+25 -4
View File
@@ -1,4 +1,3 @@
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "./sensor_numeric_device_classes";
import type { HomeAssistant } from "../types";
export const SENSOR_DEVICE_CLASS_BATTERY = "battery";
@@ -12,9 +11,6 @@ export const SENSOR_TIMESTAMP_DEVICE_CLASSES: (string | undefined)[] = [
"uptime",
];
export const isNumericSensorDeviceClass = (deviceClass?: string): boolean =>
deviceClass != null && SENSOR_NUMERIC_DEVICE_CLASSES.includes(deviceClass);
export interface SensorDeviceClassUnits {
units: string[];
}
@@ -27,3 +23,28 @@ export const getSensorDeviceClassConvertibleUnits = (
type: "sensor/device_class_convertible_units",
device_class: deviceClass,
});
export interface SensorNumericDeviceClasses {
numeric_device_classes: string[];
}
let sensorNumericDeviceClassesCache:
| Promise<SensorNumericDeviceClasses>
| undefined;
export const getSensorNumericDeviceClasses = async (
hass: HomeAssistant
): Promise<SensorNumericDeviceClasses> => {
if (sensorNumericDeviceClassesCache) {
return sensorNumericDeviceClassesCache;
}
sensorNumericDeviceClassesCache = hass
.callWS<SensorNumericDeviceClasses>({
type: "sensor/numeric_device_classes",
})
.catch((err: Error) => {
sensorNumericDeviceClassesCache = undefined;
throw err;
});
return sensorNumericDeviceClassesCache!;
};
-63
View File
@@ -1,63 +0,0 @@
// This file is auto-generated from Home Assistant Core's `SensorDeviceClass`
// (all values minus `NON_NUMERIC_DEVICE_CLASSES`). Do not edit by hand.
// Regenerate with `script/gen_numeric_device_classes`.
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
"absolute_humidity",
"apparent_power",
"aqi",
"area",
"atmospheric_pressure",
"battery",
"blood_glucose_concentration",
"carbon_dioxide",
"carbon_monoxide",
"conductivity",
"current",
"data_rate",
"data_size",
"distance",
"duration",
"energy",
"energy_distance",
"energy_storage",
"frequency",
"gas",
"humidity",
"illuminance",
"irradiance",
"moisture",
"monetary",
"nitrogen_dioxide",
"nitrogen_monoxide",
"nitrous_oxide",
"ozone",
"ph",
"pm1",
"pm10",
"pm25",
"pm4",
"power",
"power_factor",
"precipitation",
"precipitation_intensity",
"pressure",
"reactive_energy",
"reactive_power",
"signal_strength",
"sound_pressure",
"speed",
"sulphur_dioxide",
"temperature",
"temperature_delta",
"volatile_organic_compounds",
"volatile_organic_compounds_parts",
"voltage",
"volume",
"volume_flow_rate",
"volume_storage",
"water",
"weight",
"wind_direction",
"wind_speed",
];

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