mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-17 13:52:27 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78d4072049 | |||
| f2827d0ab6 | |||
| 22b83b20fc | |||
| 65f7d65462 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -1,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,
|
||||
});
|
||||
@@ -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- ")}`
|
||||
);
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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,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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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,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
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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,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
@@ -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, [
|
||||
|
||||
@@ -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: {} } },
|
||||
}));
|
||||
};
|
||||
@@ -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,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,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -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: [],
|
||||
}));
|
||||
};
|
||||
@@ -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,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;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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" },
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -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[] }));
|
||||
};
|
||||
@@ -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) : {};
|
||||
});
|
||||
};
|
||||
@@ -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 => ({}));
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockUpdate = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("update/list", () => []);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -234,12 +234,6 @@ export default tseslint.config(
|
||||
globals: globals.serviceworker,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["test/e2e/*.mjs"],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
html,
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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. There’s 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!
|
||||
|
||||
|
||||
@@ -372,6 +372,7 @@ export class DemoEntityState extends LitElement {
|
||||
hass.localize,
|
||||
entry.stateObj,
|
||||
hass.locale,
|
||||
[], // numericDeviceClasses
|
||||
hass.config,
|
||||
hass.entities
|
||||
)}`,
|
||||
|
||||
+7
-20
@@ -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",
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Safe bash settings
|
||||
# -e Exit on command fail
|
||||
# -u Exit on unset variable
|
||||
# -o pipefail Exit if piped command has error code
|
||||
set -eu -o pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
./node_modules/.bin/gulp gen-numeric-device-classes
|
||||
@@ -1,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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}${
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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!);
|
||||
|
||||
@@ -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
@@ -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
@@ -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!;
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user