mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-18 04:27:45 +00:00
Compare commits
106
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c4d6b50b0 | ||
|
|
f828c636a2 | ||
|
|
e91337b320 | ||
|
|
8ab7c118a9 | ||
|
|
0eafb1f4b4 | ||
|
|
9a7722a02e | ||
|
|
3e184eeb39 | ||
|
|
61be4e2952 | ||
|
|
315cf97e56 | ||
|
|
771a8aa4c9 | ||
|
|
d9aa649854 | ||
|
|
7a087cd6d0 | ||
|
|
32a30468bb | ||
|
|
0ace3cedfb | ||
|
|
aba7478e26 | ||
|
|
c8b2e5160a | ||
|
|
4c46ca0918 | ||
|
|
0483106f61 | ||
|
|
7e0c572f69 | ||
|
|
e4438a20cb | ||
|
|
ec418498a7 | ||
|
|
8122baca00 | ||
|
|
79738dd257 | ||
|
|
f23c8f0e23 | ||
|
|
f578cb0526 | ||
|
|
78878897cc | ||
|
|
9472d8ad12 | ||
|
|
94cbe4467b | ||
|
|
043abb69af | ||
|
|
b6b8fe641f | ||
|
|
70bb08b101 | ||
|
|
4259ddee24 | ||
|
|
6c5ac1ea84 | ||
|
|
5224fa2048 | ||
|
|
22ca986cce | ||
|
|
7f8bf69424 | ||
|
|
92224411e1 | ||
|
|
b52d58eccb | ||
|
|
a9cc47888a | ||
|
|
ea0ceecbfc | ||
|
|
5f007a1575 | ||
|
|
f360a22927 | ||
|
|
a67111e41f | ||
|
|
4b7d3a7e4f | ||
|
|
88be7adafa | ||
|
|
91a6d737b3 | ||
|
|
22c3a6fe67 | ||
|
|
bcc799970a | ||
|
|
31d4a37c15 | ||
|
|
49ea96e091 | ||
|
|
08b33ccbc1 | ||
|
|
048e754149 | ||
|
|
3a30ea5973 | ||
|
|
f7836fd3d5 | ||
|
|
03d8c092ce | ||
|
|
b3aa3c83d5 | ||
|
|
85c7d071fe | ||
|
|
6fe34bbf7e | ||
|
|
5291a84c87 | ||
|
|
c0575fcb42 | ||
|
|
ea98a85088 | ||
|
|
a1370e331f | ||
|
|
75e862540f | ||
|
|
fd0fdb37fe | ||
|
|
4893e65841 | ||
|
|
38946ad69d | ||
|
|
c0b296a5a2 | ||
|
|
8b9cf84ee8 | ||
|
|
c61622de43 | ||
|
|
affce99c9b | ||
|
|
22993a9d0a | ||
|
|
89b7ff694d | ||
|
|
5a7c2ac175 | ||
|
|
6e007c8c9a | ||
|
|
a0969d0b08 | ||
|
|
070251f360 | ||
|
|
11d38fcd6a | ||
|
|
ce6da0c03d | ||
|
|
011af7c8ea | ||
|
|
94aa373387 | ||
|
|
c5bfbd7478 | ||
|
|
72fc9c7278 | ||
|
|
79cf8b0f04 | ||
|
|
4a8fca8bb8 | ||
|
|
6a18686b1c | ||
|
|
f456bd7dc6 | ||
|
|
78a2d4d6df | ||
|
|
0f8d7b4d41 | ||
|
|
cf3009f408 | ||
|
|
bdf629f836 | ||
|
|
464b58af04 | ||
|
|
29a75209ac | ||
|
|
f89b3c23c7 | ||
|
|
29b631b960 | ||
|
|
dd94a7bac1 | ||
|
|
34154195c8 | ||
|
|
0b34fcb559 | ||
|
|
ea5a1bca99 | ||
|
|
7ae5dafc95 | ||
|
|
52cc65cbfc | ||
|
|
f36330274d | ||
|
|
a4912d0706 | ||
|
|
3900a804f0 | ||
|
|
cd5dfdb86f | ||
|
|
a602865117 | ||
|
|
f42a5012a9 |
@@ -11,6 +11,9 @@ inputs:
|
||||
is-test:
|
||||
description: Set IS_TEST for the build (skips source maps and compression)
|
||||
default: "false"
|
||||
rspack-cache:
|
||||
description: rspack persistent cache mode ("readwrite", "readonly", or "" to disable)
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -21,3 +24,4 @@ runs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.github-token }}
|
||||
IS_TEST: ${{ inputs.is-test }}
|
||||
RSPACK_CACHE: ${{ inputs.rspack-cache }}
|
||||
|
||||
@@ -97,10 +97,12 @@ jobs:
|
||||
run: yarn run test
|
||||
build:
|
||||
name: Build frontend
|
||||
needs:
|
||||
- prepare-dependencies
|
||||
- lint
|
||||
- test
|
||||
# Runs alongside lint and test rather than after them: the build only needs
|
||||
# the dependency tree, and with the rspack cache it is no longer expensive
|
||||
# enough to be worth serialising behind the other checks. The
|
||||
# cancel-on-failure job below stops the run as soon as a check fails, so a
|
||||
# broken pull request does not finish building.
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
@@ -111,12 +113,26 @@ jobs:
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
# Read-only reuse of the rspack cache written by the nightly (see
|
||||
# nightly.yaml). rspack itself decides what is still valid (version +
|
||||
# buildDependencies + node_modules snapshot), so the GHA key just restores
|
||||
# the latest nightly cache; no fingerprint, and no save step (CI never
|
||||
# writes the shared cache).
|
||||
- name: Restore rspack cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .rspack-cache
|
||||
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
rspack-cache-${{ runner.os }}-
|
||||
- name: Build Application
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-app
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
rspack-cache: readonly
|
||||
- name: Upload bundle stats
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
@@ -132,3 +148,54 @@ jobs:
|
||||
path: hass_frontend/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# Now that the checks run in parallel, a failing lint or test no longer stops
|
||||
# the build from finishing on its own, so this watches them and cancels the
|
||||
# whole run on the first failure.
|
||||
#
|
||||
# It is a separate job on purpose. Cancelling needs `actions: write`, and the
|
||||
# other jobs check out the pull request and run its build scripts — handing
|
||||
# them that scope would give PR-controlled code (or a compromised dependency)
|
||||
# write access to Actions. This job never checks out the repository, so the
|
||||
# elevated token stays away from PR code. It also cannot be a job that
|
||||
# `needs` the checks: that would only start once they have all finished, which
|
||||
# is exactly too late to cancel anything.
|
||||
cancel-on-failure:
|
||||
name: Cancel run on failure
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Cancel the run when a check fails
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
watched='^(Lint and check format|Run tests|Build frontend)$'
|
||||
while :; do
|
||||
jobs=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
|
||||
--paginate --jq '.jobs[] | [.name, .status, (.conclusion // "")] | @tsv' \
|
||||
2>/dev/null || true)
|
||||
|
||||
failed=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
|
||||
'$1 ~ w && ($3 == "failure" || $3 == "timed_out") { print $1 }')
|
||||
if [ -n "$failed" ]; then
|
||||
echo "Cancelling the run, these checks failed:"
|
||||
printf '%s\n' "$failed"
|
||||
gh run cancel "$RUN_ID" --repo "$REPO" || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
found=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" '$1 ~ w' | wc -l)
|
||||
running=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
|
||||
'$1 ~ w && $2 != "completed" { print $1 }')
|
||||
if [ "$found" -ge 3 ] && [ -z "$running" ]; then
|
||||
echo "All checks finished without failure"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 15
|
||||
done
|
||||
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -137,6 +137,7 @@ jobs:
|
||||
with:
|
||||
target: build-gallery
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
|
||||
- name: Upload gallery build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
|
||||
@@ -38,11 +38,44 @@ jobs:
|
||||
run: ./script/translations_download
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
# The wheel only builds the app (build-app), which does not merge
|
||||
# backend translations. Skipping the whole-project backend export (as
|
||||
# the release does) keeps this off the build's critical path; the full
|
||||
# translations artifact is produced in parallel by the job below.
|
||||
SKIP_BACKEND_TRANSLATIONS: "1"
|
||||
|
||||
- name: Bump version
|
||||
run: script/version_bump.js nightly
|
||||
|
||||
# Warm the shared compression cache so releases reuse it (see release.yaml).
|
||||
- name: Restore compression cache
|
||||
id: compress-cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .compress-cache
|
||||
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
restore-keys: |
|
||||
compress-cache-${{ runner.os }}-
|
||||
|
||||
# The nightly writes the rspack persistent cache; CI reads it read-only
|
||||
# (see ci.yaml). rspack invalidates internally (version + buildDependencies
|
||||
# + node_modules snapshot), so the cache rolls forward daily and a single
|
||||
# dependency bump keeps most of it warm instead of dropping the lineage.
|
||||
- name: Restore rspack cache
|
||||
id: rspack-cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .rspack-cache
|
||||
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
restore-keys: |
|
||||
rspack-cache-${{ runner.os }}-
|
||||
|
||||
- name: Build nightly Python wheels
|
||||
env:
|
||||
COMPRESS_CACHE_DIR: ${{ github.workspace }}/.compress-cache
|
||||
RSPACK_CACHE: readwrite
|
||||
run: |
|
||||
pip install build
|
||||
yarn install
|
||||
@@ -51,8 +84,23 @@ jobs:
|
||||
rm -rf dist home_assistant_frontend.egg-info
|
||||
python3 -m build
|
||||
|
||||
- name: Archive translations
|
||||
run: tar -czvf translations.tar.gz translations
|
||||
# Not gated on the restore steps: a transient restore failure (they are
|
||||
# continue-on-error) must not stop us persisting a freshly built cache.
|
||||
- name: Save compression cache
|
||||
if: success()
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .compress-cache
|
||||
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Save rspack cache
|
||||
if: success()
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .rspack-cache
|
||||
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -61,6 +109,31 @@ jobs:
|
||||
path: dist/home_assistant_frontend*.whl
|
||||
if-no-files-found: error
|
||||
|
||||
# The full translations (including the slow backend/core export) are only
|
||||
# needed for the uploaded artifact, not the wheel, so they are downloaded in
|
||||
# parallel here instead of blocking the build above.
|
||||
translations:
|
||||
name: Translations
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
immutable: false
|
||||
|
||||
- name: Download translations
|
||||
run: ./script/translations_download
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
|
||||
- name: Archive translations
|
||||
run: tar -czvf translations.tar.gz translations
|
||||
|
||||
- name: Upload translations
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
|
||||
@@ -18,6 +18,6 @@ jobs:
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
|
||||
- uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@ab22029681aa532bfe7de5774a9972d67bfbd2c0 # master
|
||||
uses: home-assistant/actions/helpers/verify-version@a7c616ce81ccda50150bf1595786c71b1883fabb # master
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
@@ -48,15 +48,44 @@ jobs:
|
||||
run: ./script/translations_download
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
# The app fetches core (backend) translations live from HA, so the
|
||||
# release build does not need Lokalise's backend project.
|
||||
SKIP_BACKEND_TRANSLATIONS: "1"
|
||||
|
||||
# Restore the content-addressed compression cache. Unchanged chunks and
|
||||
# static assets are then reused instead of re-run through brotli/zopfli.
|
||||
- name: Restore compression cache
|
||||
id: compress-cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .compress-cache
|
||||
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
restore-keys: |
|
||||
compress-cache-${{ runner.os }}-
|
||||
|
||||
- name: Build and release package
|
||||
env:
|
||||
COMPRESS_CACHE_DIR: ${{ github.workspace }}/.compress-cache
|
||||
run: |
|
||||
python3 -m pip install build
|
||||
export SKIP_FETCH_NIGHTLY_TRANSLATIONS=1
|
||||
script/release
|
||||
|
||||
# The build keeps the cache under its size budget, so saving stays bounded.
|
||||
# A unique key always writes; restore-keys picks the newest on the next
|
||||
# run. Not gated on the restore step: a transient restore failure (it is
|
||||
# continue-on-error) must not stop us persisting a freshly built cache.
|
||||
- name: Save compression cache
|
||||
if: success()
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .compress-cache
|
||||
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
@@ -76,19 +105,21 @@ jobs:
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
run: |
|
||||
version=$(echo "$GITHUB_REF" | awk -F"/" '{print $NF}' )
|
||||
# Wait for the package to become available on PyPI
|
||||
echo "Waiting for home-assistant-frontend==$version to appear on PyPI..."
|
||||
# Wait for the exact wheel to appear on the simple index (the surface
|
||||
# the wheels build's pip uses). The JSON API can report a version as
|
||||
# available before it propagates here, which fails the wheels build.
|
||||
wheel="home_assistant_frontend-${version}-py3-none-any.whl"
|
||||
echo "Waiting for $wheel to appear on the PyPI simple index..."
|
||||
for i in $(seq 1 30); do
|
||||
status=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/home-assistant-frontend/$version/json")
|
||||
if [ "$status" = "200" ]; then
|
||||
echo "Package is available on PyPI!"
|
||||
if curl -sf "https://pypi.org/simple/home-assistant-frontend/" | grep -qF "$wheel"; then
|
||||
echo "Package is available on the PyPI simple index!"
|
||||
break
|
||||
fi
|
||||
if [ "$i" = "30" ]; then
|
||||
echo "Timed out waiting for package to appear on PyPI"
|
||||
exit 1
|
||||
fi
|
||||
echo "Not available yet (HTTP $status), retrying in 30 seconds... ($i/30)"
|
||||
echo "Not available yet, retrying in 30 seconds... ($i/30)"
|
||||
sleep 30
|
||||
done
|
||||
echo "home-assistant-frontend==$version" > ./requirements.txt
|
||||
@@ -123,6 +154,8 @@ jobs:
|
||||
run: ./script/translations_download
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
# The landing-page build does not merge backend translations.
|
||||
SKIP_BACKEND_TRANSLATIONS: "1"
|
||||
- name: Build landing-page
|
||||
run: landing-page/script/build_landing_page
|
||||
- name: Tar folder
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 90 days stale policy
|
||||
uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 90
|
||||
|
||||
@@ -6,6 +6,8 @@ build/
|
||||
dist/
|
||||
/hass_frontend/
|
||||
/translations/
|
||||
/.compress-cache/
|
||||
/.rspack-cache/
|
||||
# Composite action source, not build output
|
||||
!/.github/actions/build/
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
import { availableParallelism } from "node:os";
|
||||
import { buffer as readStream } from "node:stream/consumers";
|
||||
import { promisify } from "node:util";
|
||||
import { brotliCompress } from "node:zlib";
|
||||
import { brotliCompress, constants } from "node:zlib";
|
||||
import { withCache } from "./compress-cache.mjs";
|
||||
import { ParallelTransform } from "./parallel-transform.mjs";
|
||||
|
||||
const EXTENSION = ".br";
|
||||
@@ -24,8 +25,13 @@ const compress = promisify(brotliCompress);
|
||||
* @param {boolean} [options.skipLarger] Drop files that compression grows.
|
||||
* @param {object} [options.params] Brotli parameters, passed to zlib as-is.
|
||||
*/
|
||||
export default ({ skipLarger = false, params } = {}) =>
|
||||
new ParallelTransform(availableParallelism(), async (file) => {
|
||||
export default ({ skipLarger = false, params } = {}) => {
|
||||
// Isolate cache entries by anything that changes the output bytes: the brotli
|
||||
// quality, and the node major that produced them.
|
||||
const quality = params?.[constants.BROTLI_PARAM_QUALITY] ?? "default";
|
||||
const namespace = `brotli-q${quality}-node${process.versions.node.split(".")[0]}`;
|
||||
|
||||
return new ParallelTransform(availableParallelism(), async (file) => {
|
||||
if (file.isNull()) {
|
||||
return file;
|
||||
}
|
||||
@@ -33,10 +39,13 @@ export default ({ skipLarger = false, params } = {}) =>
|
||||
file.contents = await readStream(file.contents);
|
||||
}
|
||||
|
||||
const compressed = await compress(file.contents, { params });
|
||||
if (skipLarger && compressed.length >= file.contents.length) {
|
||||
const compressed = await withCache(namespace, file.contents, async () => {
|
||||
const out = await compress(file.contents, { params });
|
||||
// Dropped rather than passed through, as gulp-brotli did: the
|
||||
// uncompressed file is already in the output directory.
|
||||
return skipLarger && out.length >= file.contents.length ? undefined : out;
|
||||
});
|
||||
if (compressed === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -44,3 +53,4 @@ export default ({ skipLarger = false, params } = {}) =>
|
||||
file.path += EXTENSION;
|
||||
return file;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
|
||||
"frontend-modern": {
|
||||
"app": 595204,
|
||||
"core": 57741,
|
||||
"authorize": 576928,
|
||||
"onboarding": 685964
|
||||
"app": 585518,
|
||||
"core": 57048,
|
||||
"authorize": 552423,
|
||||
"onboarding": 666818
|
||||
},
|
||||
"frontend-legacy": {
|
||||
"app": 861452,
|
||||
"core": 258557,
|
||||
"authorize": 834356,
|
||||
"onboarding": 1001360
|
||||
"app": 887384,
|
||||
"core": 244482,
|
||||
"authorize": 844995,
|
||||
"onboarding": 1008816
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ module.exports.config = {
|
||||
};
|
||||
},
|
||||
|
||||
gallery({ isProdBuild, latestBuild }) {
|
||||
gallery({ isProdBuild, latestBuild, isTestBuild }) {
|
||||
return {
|
||||
name: "gallery" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
@@ -287,6 +287,7 @@ module.exports.config = {
|
||||
publicPath: publicPath(latestBuild),
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isTestBuild,
|
||||
defineOverlay: {
|
||||
__DEMO__: true,
|
||||
},
|
||||
@@ -311,7 +312,15 @@ module.exports.config = {
|
||||
return {
|
||||
name: "e2e-test-app" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
dashboard: path.resolve(
|
||||
paths.e2eTestApp_dir,
|
||||
"src/dashboard-entrypoint.ts"
|
||||
),
|
||||
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
|
||||
onboarding: path.resolve(
|
||||
paths.e2eTestApp_dir,
|
||||
"src/onboarding-entrypoint.ts"
|
||||
),
|
||||
},
|
||||
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
|
||||
publicPath: publicPath(latestBuild),
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// Content-addressed cache for compression output.
|
||||
//
|
||||
// brotli (quality 11) and zopfli are the slowest part of a production build,
|
||||
// and they redo every file from scratch on every run. But production chunks are
|
||||
// content-hashed in their filenames, so a chunk that didn't change produces
|
||||
// byte-identical input here. Keying the compressed output by a hash of the
|
||||
// input bytes lets an unchanged file skip compression entirely, and lets a
|
||||
// nightly build warm the cache a release reuses the next day.
|
||||
//
|
||||
// Disabled unless COMPRESS_CACHE_DIR points at a directory. Local builds set
|
||||
// nothing and behave exactly as before. The compressed bytes are unchanged
|
||||
// either way; only whether they were recomputed differs.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
utimes,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const rootDir = process.env.COMPRESS_CACHE_DIR;
|
||||
export const cacheEnabled = Boolean(rootDir);
|
||||
|
||||
// Total cache size to keep across builds, least-recently-used first when over.
|
||||
// The cache is shared between branches (a dev nightly warms it for a release,
|
||||
// which builds from rc/master), so pruning must NOT drop everything the current
|
||||
// build didn't touch — that would make each branch evict the other's
|
||||
// branch-specific files every run. Instead the current build is pinned and the
|
||||
// rest is kept up to this budget, so nothing is dropped unless the cache is
|
||||
// genuinely too large. Override with COMPRESS_CACHE_MAX_BYTES.
|
||||
const DEFAULT_MAX_BYTES = 1024 * 1024 * 1024; // 1 GiB
|
||||
const maxBytes = () => {
|
||||
// A valid, explicit budget wins — including 0, which keeps only the current
|
||||
// build (evict everything else). An unset or invalid value uses the default.
|
||||
const configured = Number(process.env.COMPRESS_CACHE_MAX_BYTES);
|
||||
return Number.isFinite(configured) && configured >= 0
|
||||
? configured
|
||||
: DEFAULT_MAX_BYTES;
|
||||
};
|
||||
|
||||
// Every cache key touched this build, hits and writes alike, so a prune can pin
|
||||
// the current dist and never evict a file this build depends on.
|
||||
const touched = new Set();
|
||||
|
||||
// Per-namespace directory, created lazily and only once.
|
||||
const dirs = new Map();
|
||||
|
||||
let tmpCounter = 0;
|
||||
|
||||
const sha256 = (contents) =>
|
||||
createHash("sha256").update(contents).digest("hex");
|
||||
|
||||
const namespaceDir = (namespace) => {
|
||||
let dir = dirs.get(namespace);
|
||||
if (!dir) {
|
||||
const dirPath = path.join(rootDir, namespace);
|
||||
dir = { path: dirPath, ready: undefined };
|
||||
dirs.set(namespace, dir);
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
|
||||
const ensureDir = (dir) => {
|
||||
dir.ready ??= mkdir(dir.path, { recursive: true });
|
||||
return dir.ready;
|
||||
};
|
||||
|
||||
// Written via a unique temp file and renamed into place so a concurrent reader
|
||||
// in the same build never sees a half-written entry (rename is atomic on the
|
||||
// same filesystem).
|
||||
const writeAtomic = async (dir, name, contents) => {
|
||||
await ensureDir(dir);
|
||||
tmpCounter += 1;
|
||||
const tmp = path.join(dir.path, `.${process.pid}-${tmpCounter}.tmp`);
|
||||
const dest = path.join(dir.path, name);
|
||||
await writeFile(tmp, contents);
|
||||
try {
|
||||
await rename(tmp, dest);
|
||||
} catch (error) {
|
||||
// Two files with identical contents can miss and write the same entry at
|
||||
// once. On POSIX the rename just overwrites, but Windows rejects a rename
|
||||
// onto an existing path. Either way the destination already holds the same
|
||||
// bytes (content-addressed), so drop our temp and treat it as done.
|
||||
if (existsSync(dest)) {
|
||||
await rm(tmp, { force: true });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the cached compression result for `contents`, or run `compute` and
|
||||
* cache what it returns. `compute` resolves to a Buffer for compressed output,
|
||||
* or `undefined` when the file should be dropped (brotli skipLarger); both
|
||||
* outcomes are cached, so a dropped file is not recompressed on the next build.
|
||||
*
|
||||
* @param {string} namespace Isolates entries by algorithm, parameters and tool
|
||||
* version, so a change to any of them can never return a stale result.
|
||||
* @param {Buffer} contents Uncompressed input bytes, used as the cache key.
|
||||
* @param {() => Promise<Buffer | undefined>} compute Runs on a cache miss.
|
||||
* @returns {Promise<Buffer | undefined>}
|
||||
*/
|
||||
export const withCache = async (namespace, contents, compute) => {
|
||||
if (!cacheEnabled) {
|
||||
return compute();
|
||||
}
|
||||
|
||||
const dir = namespaceDir(namespace);
|
||||
const hash = sha256(contents);
|
||||
touched.add(`${namespace}/${hash}`);
|
||||
|
||||
const dataPath = path.join(dir.path, hash);
|
||||
const dropName = `${hash}.drop`;
|
||||
|
||||
try {
|
||||
return await readFile(dataPath);
|
||||
} catch {
|
||||
// Miss (or unreadable) — fall through and compute.
|
||||
}
|
||||
if (existsSync(path.join(dir.path, dropName))) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = await compute();
|
||||
if (result === undefined) {
|
||||
await writeAtomic(dir, dropName, "");
|
||||
} else {
|
||||
await writeAtomic(dir, hash, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keep the cache under `maxBytes`, evicting least-recently-used entries first.
|
||||
* Files this build touched are always kept (and their timestamp refreshed, so
|
||||
* shared files stay warm across branches); the remainder — including another
|
||||
* branch's entries — is kept up to the budget. No-op when the cache is disabled
|
||||
* or nothing was compressed, so it never wipes a warm cache on a build that
|
||||
* skipped compression.
|
||||
*/
|
||||
export const pruneCache = async () => {
|
||||
if (!cacheEnabled || touched.size === 0 || !existsSync(rootDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const pinned = [];
|
||||
const others = [];
|
||||
|
||||
// Scan every namespace on disk, not just the ones this build used, so entries
|
||||
// from an old tool version (a different namespace) are eligible for eviction.
|
||||
const namespaces = await readdir(rootDir, { withFileTypes: true });
|
||||
await Promise.all(
|
||||
namespaces.map(async (ns) => {
|
||||
if (!ns.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
const nsDir = path.join(rootDir, ns.name);
|
||||
const entries = await readdir(nsDir);
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryPath = path.join(nsDir, entry);
|
||||
// Stray temp files from an interrupted write are never valid entries.
|
||||
if (entry.endsWith(".tmp")) {
|
||||
await rm(entryPath, { force: true });
|
||||
return;
|
||||
}
|
||||
const hash = entry.endsWith(".drop") ? entry.slice(0, -5) : entry;
|
||||
const info = await stat(entryPath);
|
||||
if (touched.has(`${ns.name}/${hash}`)) {
|
||||
pinned.push({ entryPath, size: info.size });
|
||||
} else {
|
||||
others.push({ entryPath, size: info.size, mtimeMs: info.mtimeMs });
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// Pinned files stay and are refreshed so they rank as most-recently-used for
|
||||
// future prunes; they always count against the budget first.
|
||||
let kept = 0;
|
||||
await Promise.all(
|
||||
pinned.map(async ({ entryPath, size }) => {
|
||||
kept += size;
|
||||
// A failed timestamp refresh only affects future LRU ordering, not
|
||||
// correctness, so it is safe to ignore.
|
||||
await utimes(entryPath, now, now).catch(() => undefined);
|
||||
})
|
||||
);
|
||||
|
||||
// Keep the most-recently-used others until the budget is spent; drop the rest.
|
||||
others.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
const budget = maxBytes();
|
||||
const toDelete = [];
|
||||
for (const entry of others) {
|
||||
if (kept + entry.size <= budget) {
|
||||
kept += entry.size;
|
||||
} else {
|
||||
toDelete.push(entry.entryPath);
|
||||
}
|
||||
}
|
||||
await Promise.all(toDelete.map((p) => rm(p, { force: true })));
|
||||
};
|
||||
@@ -50,7 +50,9 @@ gulp.task(
|
||||
"rspack-prod-app",
|
||||
gulp.parallel("gen-pages-app-prod", "gen-service-worker-app-prod"),
|
||||
// Don't compress running tests
|
||||
...(env.isTestBuild() || env.isStatsBuild() ? [] : ["compress-app"])
|
||||
...(env.isTestBuild() || env.isStatsBuild()
|
||||
? []
|
||||
: ["compress-app", "prune-compress-cache"])
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { constants } from "node:zlib";
|
||||
import gulp from "gulp";
|
||||
import brotli from "../brotli.mjs";
|
||||
import { pruneCache } from "../compress-cache.mjs";
|
||||
import paths from "../paths.cjs";
|
||||
import zopfli from "../zopfli.mjs";
|
||||
|
||||
@@ -57,3 +58,7 @@ gulp.task(
|
||||
compressAppOtherZopfli
|
||||
)
|
||||
);
|
||||
|
||||
// Keep the compression cache under its size budget (LRU, this build pinned).
|
||||
// No-op unless COMPRESS_CACHE_DIR is set.
|
||||
gulp.task("prune-compress-cache", () => pruneCache());
|
||||
|
||||
@@ -155,8 +155,22 @@ gulp.task("fetch-lokalise", async function () {
|
||||
fs.mkdir(inDirBackend, { recursive: true }),
|
||||
]);
|
||||
|
||||
// The backend project only provides entity_component translations, which are
|
||||
// merged into the demo, gallery, cast and e2e builds. The shipped app fetches
|
||||
// them live from core, so builds that only produce the app (release, release
|
||||
// landing-page) can skip this second, whole-project export to save time.
|
||||
const projects = Object.entries(lokaliseProjects).filter(
|
||||
([project]) =>
|
||||
!(project === "backend" && process.env.SKIP_BACKEND_TRANSLATIONS)
|
||||
);
|
||||
if (projects.length !== Object.keys(lokaliseProjects).length) {
|
||||
console.log(
|
||||
"Skipping backend translations download (SKIP_BACKEND_TRANSLATIONS)"
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(lokaliseProjects).map(async ([project, projectId]) => {
|
||||
projects.map(async ([project, projectId]) => {
|
||||
try {
|
||||
const exportProcess = await lokaliseApi
|
||||
.files()
|
||||
|
||||
@@ -303,7 +303,11 @@ gulp.task(
|
||||
)
|
||||
);
|
||||
|
||||
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
|
||||
const E2E_TEST_APP_PAGE_ENTRIES = {
|
||||
"index.html": ["main"],
|
||||
"dashboard.html": ["dashboard"],
|
||||
"onboarding.html": ["onboarding"],
|
||||
};
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-e2e-test-app-dev",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import merge from "lodash.merge";
|
||||
|
||||
const isMergeableObject = (value) =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
// Keys that must never be written to, to avoid prototype pollution when the
|
||||
// overlay comes from an untrusted source (JSON.parse can produce an own
|
||||
// `__proto__` key from `{"__proto__": ...}`).
|
||||
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
||||
|
||||
// Deep-merge `overlay` onto `base`, keeping only keys that already exist as own
|
||||
// properties of `base`. Overlay keys with no counterpart in the base - e.g.
|
||||
// translations for source strings that have since been removed from or renamed
|
||||
// in en.json but still linger in Lokalise - are dropped so we don't ship stale
|
||||
// keys. `base` is mutated and returned.
|
||||
export const restrictedMerge = (base, overlay) => {
|
||||
for (const key of Object.keys(overlay)) {
|
||||
// Own-property check (not `in`) so inherited keys like `__proto__` or
|
||||
// `toString` from the overlay are ignored rather than merged.
|
||||
if (FORBIDDEN_KEYS.has(key) || !Object.hasOwn(base, key)) {
|
||||
continue;
|
||||
}
|
||||
const baseValue = base[key];
|
||||
const overlayValue = overlay[key];
|
||||
if (isMergeableObject(baseValue) && isMergeableObject(overlayValue)) {
|
||||
restrictedMerge(baseValue, overlayValue);
|
||||
} else if (
|
||||
!isMergeableObject(baseValue) &&
|
||||
!isMergeableObject(overlayValue)
|
||||
) {
|
||||
base[key] = overlayValue;
|
||||
}
|
||||
// Mismatched shapes keep the base (English) value as a safe fallback.
|
||||
}
|
||||
return base;
|
||||
};
|
||||
|
||||
// Merge translation `objects` onto `startObj`. When `prune` is set, the result
|
||||
// is restricted to the key shape of `startObj` (the English master), so keys
|
||||
// that no longer exist in en.json are not shipped. Otherwise keys are merged
|
||||
// additively (used when building the English master itself, which starts empty).
|
||||
export const mergeTranslations = (startObj, objects, prune = false) =>
|
||||
prune
|
||||
? objects.reduce(restrictedMerge, startObj)
|
||||
: merge(startObj, ...objects);
|
||||
@@ -252,6 +252,7 @@ gulp.task("rspack-prod-gallery", () =>
|
||||
createGalleryConfig({
|
||||
isProdBuild: true,
|
||||
latestBuild: true,
|
||||
isTestBuild: env.isTestBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { deleteAsync } from "del";
|
||||
import { glob } from "glob";
|
||||
import gulp from "gulp";
|
||||
import rename from "gulp-rename";
|
||||
import merge from "lodash.merge";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
@@ -10,6 +9,7 @@ import { PassThrough, Transform } from "node:stream";
|
||||
import { finished } from "node:stream/promises";
|
||||
import env from "../env.cjs";
|
||||
import paths from "../paths.cjs";
|
||||
import { mergeTranslations } from "./merge-translations.js";
|
||||
import "./fetch-nightly-translations.js";
|
||||
|
||||
const inFrontendDir = "translations/frontend";
|
||||
@@ -56,11 +56,12 @@ class CustomJSON extends Transform {
|
||||
class MergeJSON extends Transform {
|
||||
_objects = [];
|
||||
|
||||
constructor(stem, startObj = {}, reviver = null) {
|
||||
constructor(stem, startObj = {}, reviver = null, prune = false) {
|
||||
super({ objectMode: true, allowHalfOpen: false });
|
||||
this._stem = stem;
|
||||
this._startObj = structuredClone(startObj);
|
||||
this._reviver = reviver;
|
||||
this._prune = prune;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -72,7 +73,11 @@ class MergeJSON extends Transform {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
async _flush(callback) {
|
||||
const mergedObj = merge(this._startObj, ...this._objects);
|
||||
const mergedObj = mergeTranslations(
|
||||
this._startObj,
|
||||
this._objects,
|
||||
this._prune
|
||||
);
|
||||
this._outFile.contents = Buffer.from(JSON.stringify(mergedObj));
|
||||
this._outFile.stem = this._stem;
|
||||
callback(null, this._outFile);
|
||||
@@ -257,7 +262,7 @@ const createTranslations = async () => {
|
||||
}
|
||||
const mergeStream = gulp
|
||||
.src(mergeFiles, { allowEmpty: true })
|
||||
.pipe(new MergeJSON(locale, enMaster, emptyReviver));
|
||||
.pipe(new MergeJSON(locale, enMaster, emptyReviver, true));
|
||||
mergesFinished.push(finished(mergeStream));
|
||||
mergeStream.pipe(hashStream, { end: false });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const { existsSync } = require("fs");
|
||||
const fs = require("fs");
|
||||
|
||||
const { existsSync } = fs;
|
||||
const path = require("path");
|
||||
const rspack = require("@rspack/core");
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -16,6 +18,61 @@ const SafeWebpackBar = require("./safe-webpackbar.cjs");
|
||||
const paths = require("./paths.cjs");
|
||||
const bundle = require("./bundle.cjs");
|
||||
|
||||
// Build-toolchain packages whose version changes the emitted bytes but which
|
||||
// are loader/compiler machinery, not modules in the build graph — so rspack's
|
||||
// node_modules snapshot cannot see them. Their versions are folded into the
|
||||
// persistent cache `version` so a toolchain upgrade invalidates the cache,
|
||||
// while ordinary runtime-dependency bumps (handled by the snapshot) do not.
|
||||
const TOOLCHAIN_PACKAGES = [
|
||||
"@rspack/core",
|
||||
"@babel/core",
|
||||
"@babel/preset-env",
|
||||
"babel-plugin-polyfill-corejs3",
|
||||
"@babel/plugin-transform-runtime",
|
||||
"@babel/plugin-transform-class-properties",
|
||||
"@babel/plugin-transform-private-methods",
|
||||
"@babel/runtime",
|
||||
"babel-loader",
|
||||
"core-js",
|
||||
"terser",
|
||||
"terser-webpack-plugin",
|
||||
"browserslist",
|
||||
"caniuse-lite",
|
||||
];
|
||||
|
||||
// Our own build logic — the config, loaders and babel plugins. Their contents
|
||||
// (not their paths) go into the cache version, so a change invalidates the
|
||||
// cache the same way `buildDependencies` would, but without tying validity to
|
||||
// absolute paths — rspack compares buildDependencies by path, which breaks a
|
||||
// cache reused on another machine/checkout (a different workspace path).
|
||||
const CONFIG_FILES = [
|
||||
__filename,
|
||||
path.join(__dirname, "bundle.cjs"),
|
||||
path.join(__dirname, "minify-template-literals-loader.cjs"),
|
||||
path.join(__dirname, "lit-disable-dev-mode-loader.cjs"),
|
||||
path.join(__dirname, "babel-plugins", "custom-polyfill-plugin.js"),
|
||||
path.join(__dirname, "babel-plugins", "inline-constants-plugin.cjs"),
|
||||
];
|
||||
|
||||
// Content hash of the toolchain versions and our own build files, used as the
|
||||
// persistent cache `version`. Everything here is path-independent so the cache
|
||||
// stays valid when reused on a different machine or checkout path.
|
||||
const cacheVersion = () => {
|
||||
const parts = [
|
||||
...TOOLCHAIN_PACKAGES.map(
|
||||
(pkg) => `${pkg}@${require(`${pkg}/package.json`).version}`
|
||||
),
|
||||
...CONFIG_FILES.map(
|
||||
(file) => `${path.basename(file)}:${fs.readFileSync(file, "utf8")}`
|
||||
),
|
||||
];
|
||||
return require("crypto")
|
||||
.createHash("sha256")
|
||||
.update(parts.join("\n"))
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
};
|
||||
|
||||
class LogStartCompilePlugin {
|
||||
ignoredFirst = false;
|
||||
|
||||
@@ -376,6 +433,33 @@ const createRspackConfig = ({
|
||||
])
|
||||
),
|
||||
},
|
||||
// Persistent filesystem cache for production builds, opt-in per environment
|
||||
// via RSPACK_CACHE ("readwrite" writes it, "readonly" only reads a warm
|
||||
// cache — e.g. CI reusing the nightly-written one). Unset (releases, local,
|
||||
// tests) = no cache.
|
||||
...(isProdBuild && process.env.RSPACK_CACHE
|
||||
? {
|
||||
cache: {
|
||||
type: "persistent",
|
||||
// `name` is already unique per variant (frontend-modern/-legacy).
|
||||
name,
|
||||
// Content-based version (node major + toolchain versions + our own
|
||||
// build files). Everything is path-independent, so the cache stays
|
||||
// valid when reused on another machine/checkout. Runtime deps are
|
||||
// deliberately absent — rspack's node_modules snapshot invalidates
|
||||
// their modules per-package, so a single unrelated bump keeps the
|
||||
// rest warm. buildDependencies is intentionally not used: rspack
|
||||
// compares it by absolute path, which breaks cross-machine reuse.
|
||||
version: `node${process.versions.node.split(".")[0]}-${cacheVersion()}`,
|
||||
storage: {
|
||||
type: "filesystem",
|
||||
directory: path.resolve(paths.root_dir, ".rspack-cache"),
|
||||
},
|
||||
// CI reads the nightly-written cache but must not modify it.
|
||||
readonly: process.env.RSPACK_CACHE === "readonly",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
experiments: {
|
||||
outputModule: true,
|
||||
},
|
||||
@@ -405,8 +489,10 @@ const createDemoConfig = ({
|
||||
const createCastConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.cast({ isProdBuild, latestBuild }));
|
||||
|
||||
const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.gallery({ isProdBuild, latestBuild }));
|
||||
const createGalleryConfig = ({ isProdBuild, latestBuild, isTestBuild }) =>
|
||||
createRspackConfig(
|
||||
bundle.config.gallery({ isProdBuild, latestBuild, isTestBuild })
|
||||
);
|
||||
|
||||
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
|
||||
|
||||
@@ -5,14 +5,23 @@
|
||||
// blocks the event loop for the whole compression step. Running one WASM
|
||||
// instance per worker parallelises it; the output bytes are unchanged.
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import { availableParallelism } from "node:os";
|
||||
import { buffer as readStream } from "node:stream/consumers";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { withCache } from "./compress-cache.mjs";
|
||||
import { ParallelTransform } from "./parallel-transform.mjs";
|
||||
|
||||
const WORKER_URL = new URL("./zopfli-worker.mjs", import.meta.url);
|
||||
const EXTENSION = ".gz";
|
||||
|
||||
// Cache namespace tied to the zopfli version, since a different version can
|
||||
// produce different bytes for the same input.
|
||||
const ZOPFLI_VERSION = createRequire(import.meta.url)(
|
||||
"@gfx/zopfli/package.json"
|
||||
).version;
|
||||
const NAMESPACE = `gzip-zopfli${ZOPFLI_VERSION}`;
|
||||
|
||||
// Left empty on purpose: @gfx/zopfli then applies its own defaults, which is
|
||||
// what gulp-zopfli-green did, so compressed output stays byte-identical.
|
||||
const ZOPFLI_OPTIONS = {};
|
||||
@@ -130,7 +139,9 @@ export default ({ threshold = 0 } = {}) => {
|
||||
// Passed through unrenamed and uncompressed, as gulp-zopfli-green did.
|
||||
return file;
|
||||
}
|
||||
file.contents = await compress(file.contents);
|
||||
file.contents = await withCache(NAMESPACE, file.contents, () =>
|
||||
compress(file.contents)
|
||||
);
|
||||
file.path += EXTENSION;
|
||||
return file;
|
||||
});
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
import { mdiClose, mdiFlaskOutline } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../src/common/dom/fire_event";
|
||||
import { mainWindow } from "../../../src/common/dom/get_main_window";
|
||||
import { navigate } from "../../../src/common/navigate";
|
||||
import "../../../src/components/ha-button";
|
||||
import "../../../src/components/ha-card";
|
||||
import "../../../src/components/ha-icon-button";
|
||||
import "../../../src/components/ha-svg-icon";
|
||||
import "../../../src/components/ha-switch";
|
||||
import type { HaSwitch } from "../../../src/components/ha-switch";
|
||||
import type { CloudDemoScenario } from "../stubs/cloud-demo-state";
|
||||
import {
|
||||
getCloudDemoScenario,
|
||||
setCloudDemoScenario,
|
||||
subscribeCloudDemoScenario,
|
||||
} from "../stubs/cloud-demo-state";
|
||||
|
||||
// Walk the DOM, descending into shadow roots, to find the first matching
|
||||
// element. Used to reach <ha-panel-config> (which owns the cloud status) so we
|
||||
// can ask it to re-fetch after a scenario change.
|
||||
const deepQuery = (
|
||||
selector: string,
|
||||
root: Document | ShadowRoot = document
|
||||
): Element | null => {
|
||||
const direct = root.querySelector(selector);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const elements = root.querySelectorAll("*");
|
||||
for (const element of elements) {
|
||||
const shadow = element.shadowRoot;
|
||||
if (shadow) {
|
||||
const found = deepQuery(selector, shadow);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Demo-only floating panel that flips the mocked Home Assistant Cloud state so
|
||||
* reviewers can preview every UI state of the cloud account page. It writes to
|
||||
* the shared {@link CloudDemoScenario} (which the cloud/backup mocks read) and
|
||||
* then nudges the page to re-read it. Lives entirely under demo/.
|
||||
*/
|
||||
@customElement("cloud-demo-controls")
|
||||
export class CloudDemoControls extends LitElement {
|
||||
@state() private _open = true;
|
||||
|
||||
@state() private _visible = false;
|
||||
|
||||
@state() private _scenario: CloudDemoScenario = getCloudDemoScenario();
|
||||
|
||||
private _unsub?: () => void;
|
||||
|
||||
// The demo uses hash-based routing (navigate() sets location.hash), so the
|
||||
// active route lives in the hash, not the pathname.
|
||||
private get _currentPath(): string {
|
||||
const hash = mainWindow.location.hash;
|
||||
return hash.startsWith("#/") ? hash.slice(1) : mainWindow.location.pathname;
|
||||
}
|
||||
|
||||
private _locationChanged = () => {
|
||||
this._visible = this._currentPath.startsWith("/config/cloud");
|
||||
};
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._locationChanged();
|
||||
mainWindow.addEventListener("location-changed", this._locationChanged);
|
||||
mainWindow.addEventListener("popstate", this._locationChanged);
|
||||
mainWindow.addEventListener("hashchange", this._locationChanged);
|
||||
this._unsub = subscribeCloudDemoScenario((scenario) => {
|
||||
this._scenario = { ...scenario };
|
||||
});
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
mainWindow.removeEventListener("location-changed", this._locationChanged);
|
||||
mainWindow.removeEventListener("popstate", this._locationChanged);
|
||||
mainWindow.removeEventListener("hashchange", this._locationChanged);
|
||||
this._unsub?.();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._visible) {
|
||||
return nothing;
|
||||
}
|
||||
if (!this._open) {
|
||||
return html`
|
||||
<ha-icon-button
|
||||
class="fab"
|
||||
label="Cloud demo controls"
|
||||
.path=${mdiFlaskOutline}
|
||||
@click=${this._toggleOpen}
|
||||
></ha-icon-button>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="header">
|
||||
<ha-svg-icon .path=${mdiFlaskOutline}></ha-svg-icon>
|
||||
<span class="title">Cloud demo controls</span>
|
||||
<ha-icon-button
|
||||
label="Close"
|
||||
.path=${mdiClose}
|
||||
@click=${this._toggleOpen}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
<p class="note">
|
||||
Demo only. Flips the mocked cloud state shown on this page.
|
||||
</p>
|
||||
<div class="controls">
|
||||
${this._segment("Subscription", "account", [
|
||||
["active", "Active"],
|
||||
["trialing", "Trialing"],
|
||||
["canceled", "Canceled"],
|
||||
["expired", "Expired"],
|
||||
["unknown", "Unknown"],
|
||||
])}
|
||||
${this._toggle("Onboarded", "onboarded")}
|
||||
${this._toggle("Onboarding postponed", "postponed")}
|
||||
${this._toggle("Remote access", "remote")}
|
||||
${this._segment("Remote status", "remoteStatus", [
|
||||
["ready", "Ready"],
|
||||
["generating", "Preparing"],
|
||||
["loading", "Loading"],
|
||||
["loaded", "Loaded"],
|
||||
["error", "Error"],
|
||||
])}
|
||||
${this._segment("Backups", "backup", [
|
||||
["fresh", "Recent"],
|
||||
["stale", "Old"],
|
||||
["failed", "Failed"],
|
||||
["local", "Local only"],
|
||||
["none", "None"],
|
||||
])}
|
||||
${this._toggle("Alexa linked", "alexa")}
|
||||
${this._toggle("Google linked", "google")}
|
||||
${this._toggle("Cameras (WebRTC)", "webrtc")}
|
||||
${this._toggle("Has webhooks", "webhooks")}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _segment(
|
||||
label: string,
|
||||
field: keyof CloudDemoScenario,
|
||||
options: [string, string][]
|
||||
) {
|
||||
return html`
|
||||
<div class="row">
|
||||
<span>${label}</span>
|
||||
<div class="segment">
|
||||
${options.map(
|
||||
([value, text]) => html`
|
||||
<ha-button
|
||||
size="s"
|
||||
appearance=${
|
||||
this._scenario[field] === value ? "filled" : "plain"
|
||||
}
|
||||
data-field=${field}
|
||||
data-value=${value}
|
||||
@click=${this._segmentClick}
|
||||
>
|
||||
${text}
|
||||
</ha-button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggle(label: string, field: keyof CloudDemoScenario) {
|
||||
return html`
|
||||
<div class="row">
|
||||
<span>${label}</span>
|
||||
<ha-switch
|
||||
.checked=${this._scenario[field] as boolean}
|
||||
data-field=${field}
|
||||
@change=${this._toggleChange}
|
||||
></ha-switch>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggleOpen() {
|
||||
this._open = !this._open;
|
||||
}
|
||||
|
||||
private _segmentClick(ev: Event) {
|
||||
const target = ev.currentTarget as HTMLElement;
|
||||
this._set(
|
||||
target.dataset.field as keyof CloudDemoScenario,
|
||||
target.dataset.value!
|
||||
);
|
||||
}
|
||||
|
||||
private _toggleChange(ev: Event) {
|
||||
const target = ev.target as HaSwitch;
|
||||
this._set(target.dataset.field as keyof CloudDemoScenario, target.checked);
|
||||
}
|
||||
|
||||
private _set(field: keyof CloudDemoScenario, value: string | boolean) {
|
||||
setCloudDemoScenario({ [field]: value } as Partial<CloudDemoScenario>);
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
private _refresh() {
|
||||
// Refresh the shared cloud status so login-state changes (signed out) and
|
||||
// status-derived fields update.
|
||||
const panel = deepQuery("ha-panel-config");
|
||||
if (panel) {
|
||||
fireEvent(panel as HTMLElement, "ha-refresh-cloud-status");
|
||||
}
|
||||
// cloud-account fetches its subscription/backup/webhook data once on mount
|
||||
// and is not cached by the router, so bounce through a sibling cloud route
|
||||
// to force a clean remount that re-reads the updated mocks.
|
||||
const path = this._currentPath;
|
||||
if (path.startsWith("/config/cloud") && path !== "/config/cloud/login") {
|
||||
const sibling =
|
||||
path === "/config/cloud/remote"
|
||||
? "/config/cloud/account"
|
||||
: "/config/cloud/remote";
|
||||
navigate(sibling, { replace: true });
|
||||
window.setTimeout(() => navigate(path, { replace: true }), 0);
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 9999;
|
||||
}
|
||||
.fab {
|
||||
--mdc-icon-button-size: 48px;
|
||||
--mdc-icon-size: 24px;
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-primary-color, #fff);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
ha-card {
|
||||
display: block;
|
||||
width: 320px;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 8px 8px 16px;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
.header .title {
|
||||
flex: 1;
|
||||
font-weight: var(--ha-font-weight-medium, 500);
|
||||
}
|
||||
.header ha-svg-icon {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.note {
|
||||
margin: 8px 16px;
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s, 0.875rem);
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 36px;
|
||||
}
|
||||
.segment {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"cloud-demo-controls": CloudDemoControls;
|
||||
}
|
||||
}
|
||||
+2
-6
@@ -32,7 +32,6 @@ import { mockTemplate } from "./stubs/template";
|
||||
import { mockTodo } from "./stubs/todo";
|
||||
import { mockTranslations } from "./stubs/translations";
|
||||
import { mockUsagePrediction } from "./stubs/usage_prediction";
|
||||
import "./cloud/cloud-demo-controls";
|
||||
|
||||
// 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.
|
||||
@@ -59,6 +58,8 @@ const CONFIG_PANEL_COMMANDS = [
|
||||
"search/related",
|
||||
"tag/list",
|
||||
"assist_pipeline/",
|
||||
"config/entity_registry/settings/",
|
||||
"slugify",
|
||||
];
|
||||
|
||||
@customElement("ha-demo")
|
||||
@@ -90,11 +91,6 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
},
|
||||
});
|
||||
|
||||
// Demo-only floating panel to flip the mocked cloud state. Mounted once at
|
||||
// the document level; it shows itself only on the cloud panel.
|
||||
if (!document.querySelector("cloud-demo-controls")) {
|
||||
document.body.appendChild(document.createElement("cloud-demo-controls"));
|
||||
}
|
||||
const localizePromise =
|
||||
// @ts-ignore
|
||||
this._loadFragmentTranslations(hass.language, "page-demo").then(
|
||||
|
||||
+25
-109
@@ -7,31 +7,43 @@ import type {
|
||||
import { BackupScheduleRecurrence } from "../../../src/data/backup";
|
||||
import type { ManagerStateEvent } from "../../../src/data/backup_manager";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import type { DemoCloudBackup } from "./cloud-demo-state";
|
||||
import {
|
||||
getCloudDemoScenario,
|
||||
setCloudDemoScenario,
|
||||
subscribeCloudDemoScenario,
|
||||
} from "./cloud-demo-state";
|
||||
|
||||
const CLOUD_AGENT = "cloud.cloud";
|
||||
|
||||
// Fixed "recent" backup state: an automatic backup completed 12h ago to both the
|
||||
// local and cloud agents, with the next run scheduled for tomorrow. This is the
|
||||
// healthy state the cloud overview status line and backup sub-page render.
|
||||
const now = Date.now();
|
||||
const recent = new Date(now - 12 * 3600 * 1000).toISOString();
|
||||
const future = new Date(now + 86400000).toISOString();
|
||||
|
||||
const backupInfo: BackupInfo = {
|
||||
backups: [],
|
||||
backups: [
|
||||
{
|
||||
backup_id: "demo-backup-1",
|
||||
name: "Automatic backup DEMO",
|
||||
date: recent,
|
||||
with_automatic_settings: true,
|
||||
agents: {
|
||||
"backup.local": { size: 1024 * 1024 * 512, protected: true },
|
||||
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
|
||||
},
|
||||
} as BackupContent,
|
||||
],
|
||||
agent_errors: {},
|
||||
last_attempted_automatic_backup: null,
|
||||
last_completed_automatic_backup: null,
|
||||
last_attempted_automatic_backup: recent,
|
||||
last_completed_automatic_backup: recent,
|
||||
last_action_event: { manager_state: "idle" },
|
||||
next_automatic_backup: null,
|
||||
next_automatic_backup: future,
|
||||
next_automatic_backup_additional: false,
|
||||
state: "idle",
|
||||
};
|
||||
|
||||
const backupConfig: BackupConfig = {
|
||||
automatic_backups_configured: true,
|
||||
last_attempted_automatic_backup: null,
|
||||
last_completed_automatic_backup: null,
|
||||
next_automatic_backup: null,
|
||||
last_attempted_automatic_backup: recent,
|
||||
last_completed_automatic_backup: recent,
|
||||
next_automatic_backup: future,
|
||||
next_automatic_backup_additional: false,
|
||||
create_backup: {
|
||||
agent_ids: ["backup.local", CLOUD_AGENT],
|
||||
@@ -61,88 +73,6 @@ const agentsInfo: BackupAgentsInfo = {
|
||||
],
|
||||
};
|
||||
|
||||
// Map the demo "Backups" scenario onto the mutable backup config/info, so the
|
||||
// cloud overview status line and the backup sub-page reflect the chosen state.
|
||||
const applyScenario = () => {
|
||||
const kind = getCloudDemoScenario().backup;
|
||||
const now = Date.now();
|
||||
const recent = new Date(now - 12 * 3600 * 1000).toISOString();
|
||||
const old = new Date(now - 5 * 86400000).toISOString();
|
||||
const future = new Date(now + 86400000).toISOString();
|
||||
// Comfortably past BACKUP_OVERDUE_MARGIN_HOURS (3h) so the "stale" scenario
|
||||
// actually reads as overdue rather than slipping under the margin.
|
||||
const overdue = new Date(now - 6 * 3600 * 1000).toISOString();
|
||||
|
||||
// The cloud agent is a backup target for the cloud-backed states only. For
|
||||
// "local" a backup exists but is stored locally (no cloud copy), and for
|
||||
// "none" there are no automatic backups at all.
|
||||
const cloudEnabled =
|
||||
kind === "fresh" || kind === "stale" || kind === "failed";
|
||||
backupConfig.create_backup.agent_ids = cloudEnabled
|
||||
? ["backup.local", CLOUD_AGENT]
|
||||
: ["backup.local"];
|
||||
|
||||
switch (kind) {
|
||||
case "fresh":
|
||||
backupConfig.automatic_backups_configured = true;
|
||||
backupConfig.last_completed_automatic_backup = recent;
|
||||
backupConfig.last_attempted_automatic_backup = recent;
|
||||
backupConfig.next_automatic_backup = future;
|
||||
break;
|
||||
case "local":
|
||||
// Automatic backups run, but only to the local agent.
|
||||
backupConfig.automatic_backups_configured = true;
|
||||
backupConfig.last_completed_automatic_backup = recent;
|
||||
backupConfig.last_attempted_automatic_backup = recent;
|
||||
backupConfig.next_automatic_backup = future;
|
||||
break;
|
||||
case "stale":
|
||||
backupConfig.automatic_backups_configured = true;
|
||||
backupConfig.last_completed_automatic_backup = old;
|
||||
backupConfig.last_attempted_automatic_backup = old;
|
||||
// Next scheduled backup is in the past, so it reads as overdue.
|
||||
backupConfig.next_automatic_backup = overdue;
|
||||
break;
|
||||
case "failed":
|
||||
backupConfig.automatic_backups_configured = true;
|
||||
backupConfig.last_completed_automatic_backup = old;
|
||||
// Most recent attempt is newer than the last success, so it failed.
|
||||
backupConfig.last_attempted_automatic_backup = recent;
|
||||
backupConfig.next_automatic_backup = future;
|
||||
break;
|
||||
case "none":
|
||||
backupConfig.automatic_backups_configured = false;
|
||||
backupConfig.last_completed_automatic_backup = null;
|
||||
backupConfig.last_attempted_automatic_backup = null;
|
||||
backupConfig.next_automatic_backup = null;
|
||||
break;
|
||||
}
|
||||
|
||||
backupInfo.last_completed_automatic_backup =
|
||||
backupConfig.last_completed_automatic_backup;
|
||||
backupInfo.last_attempted_automatic_backup =
|
||||
backupConfig.last_attempted_automatic_backup;
|
||||
backupInfo.next_automatic_backup = backupConfig.next_automatic_backup;
|
||||
backupInfo.backups =
|
||||
cloudEnabled && backupConfig.last_completed_automatic_backup
|
||||
? [
|
||||
{
|
||||
backup_id: "demo-backup-1",
|
||||
name: "Automatic backup DEMO",
|
||||
date: backupConfig.last_completed_automatic_backup,
|
||||
with_automatic_settings: true,
|
||||
agents: {
|
||||
"backup.local": { size: 1024 * 1024 * 512, protected: true },
|
||||
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
|
||||
},
|
||||
} as BackupContent,
|
||||
]
|
||||
: [];
|
||||
};
|
||||
|
||||
applyScenario();
|
||||
subscribeCloudDemoScenario(applyScenario);
|
||||
|
||||
export const mockBackup = (hass: MockHomeAssistant) => {
|
||||
// Fresh objects each fetch so re-reading after a mutation actually re-renders
|
||||
// (Lit change detection is identity-based; the real WS API returns new
|
||||
@@ -171,20 +101,6 @@ export const mockBackup = (hass: MockHomeAssistant) => {
|
||||
if (update.agents) {
|
||||
backupConfig.agents = { ...backupConfig.agents, ...update.agents };
|
||||
}
|
||||
// Reflect the UI-driven backup change into the demo scenario so the demo
|
||||
// controls panel stays in sync with the mocked state.
|
||||
const cloudNow = backupConfig.create_backup.agent_ids.includes(CLOUD_AGENT);
|
||||
const current = getCloudDemoScenario().backup;
|
||||
const next: DemoCloudBackup = !backupConfig.automatic_backups_configured
|
||||
? "none"
|
||||
: cloudNow
|
||||
? current === "fresh" || current === "stale" || current === "failed"
|
||||
? current
|
||||
: "fresh"
|
||||
: "local";
|
||||
if (next !== current) {
|
||||
setCloudDemoScenario({ backup: next });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
hass.mockWS(
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
// Demo-only switchable Home Assistant Cloud scenario.
|
||||
//
|
||||
// The redesigned cloud account page (src/panels/config/cloud/account) renders
|
||||
// purely from real WS data. To let reviewers preview every UI state without a
|
||||
// real cloud account, this module holds a mutable "scenario" that the cloud and
|
||||
// backup mocks read from, plus the floating <cloud-demo-controls> panel writes
|
||||
// to. It is persisted to localStorage so the choice survives the data the page
|
||||
// fetches once per visit (subscription, backup config, webhooks).
|
||||
//
|
||||
// This lives entirely under demo/ — no production code imports it.
|
||||
|
||||
import type { RemoteCertificateStatus } from "../../../src/data/cloud";
|
||||
|
||||
// The five PaymentSubscriptionState values.
|
||||
export type DemoCloudAccount =
|
||||
"active" | "trialing" | "canceled" | "expired" | "unknown";
|
||||
|
||||
// "local": automatic backups are configured, but not to the cloud agent
|
||||
// (a backup exists, just no cloud copy). "none": no automatic backups at all.
|
||||
export type DemoCloudBackup = "fresh" | "stale" | "failed" | "local" | "none";
|
||||
|
||||
export interface CloudDemoScenario {
|
||||
account: DemoCloudAccount;
|
||||
onboarded: boolean;
|
||||
// Onboarding postponed server-side (maps to onboarding_postponed); hides
|
||||
// the onboarding UI without marking it completed.
|
||||
postponed: boolean;
|
||||
remote: boolean;
|
||||
remoteStatus: RemoteCertificateStatus;
|
||||
backup: DemoCloudBackup;
|
||||
alexa: boolean;
|
||||
google: boolean;
|
||||
webrtc: boolean;
|
||||
webhooks: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_CLOUD_DEMO_SCENARIO: CloudDemoScenario = {
|
||||
account: "active",
|
||||
onboarded: true,
|
||||
postponed: false,
|
||||
remote: true,
|
||||
remoteStatus: "ready",
|
||||
backup: "fresh",
|
||||
alexa: true,
|
||||
google: true,
|
||||
webrtc: true,
|
||||
webhooks: true,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "cloudDemoScenario";
|
||||
|
||||
const readScenario = (): CloudDemoScenario => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
return { ...DEFAULT_CLOUD_DEMO_SCENARIO, ...JSON.parse(raw) };
|
||||
}
|
||||
} catch (_err) {
|
||||
// Ignore malformed or unavailable storage and fall back to the default.
|
||||
}
|
||||
return { ...DEFAULT_CLOUD_DEMO_SCENARIO };
|
||||
};
|
||||
|
||||
let scenario: CloudDemoScenario = readScenario();
|
||||
|
||||
const listeners = new Set<(scenario: CloudDemoScenario) => void>();
|
||||
|
||||
export const getCloudDemoScenario = (): CloudDemoScenario => scenario;
|
||||
|
||||
export const subscribeCloudDemoScenario = (
|
||||
listener: (scenario: CloudDemoScenario) => void
|
||||
): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
export const setCloudDemoScenario = (
|
||||
partial: Partial<CloudDemoScenario>
|
||||
): void => {
|
||||
scenario = { ...scenario, ...partial };
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(scenario));
|
||||
} catch (_err) {
|
||||
// Ignore storage failures (e.g. private mode); state still applies in-memory.
|
||||
}
|
||||
listeners.forEach((listener) => listener(scenario));
|
||||
};
|
||||
+24
-107
@@ -6,11 +6,6 @@ import { ONBOARDING_ITEMS } from "../../../src/data/cloud";
|
||||
import type { CloudTTSInfo } from "../../../src/data/cloud/tts";
|
||||
import type { Webhook } from "../../../src/data/webhook";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import {
|
||||
getCloudDemoScenario,
|
||||
setCloudDemoScenario,
|
||||
subscribeCloudDemoScenario,
|
||||
} from "./cloud-demo-state";
|
||||
|
||||
const emptyFilter = () => ({
|
||||
include_domains: [],
|
||||
@@ -34,14 +29,30 @@ const demoWebhooks: Webhook[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// A single mutable status object so that preference changes made in the demo
|
||||
// (both via the real UI and the demo scenario controls) are reflected back.
|
||||
const demoCloudhooks = Object.fromEntries(
|
||||
demoWebhooks.map((webhook) => [
|
||||
webhook.webhook_id,
|
||||
{
|
||||
webhook_id: webhook.webhook_id,
|
||||
cloudhook_id: `demo-${webhook.webhook_id}`,
|
||||
cloudhook_url: `https://hooks.nabu.casa/demo-${webhook.webhook_id}`,
|
||||
managed: false,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
// A single mutable status object seeded to one fixed demo state: an active cloud
|
||||
// subscription with remote ready, cloud backups recent, webhooks set up and voice
|
||||
// set up (Alexa linked), but cameras (WebRTC) off, so onboarding is still in
|
||||
// progress (streaming is the remaining step) and not postponed. Page-driven
|
||||
// changes (connect remote, postpone onboarding, add/delete webhooks) mutate this
|
||||
// object directly and reset on reload.
|
||||
const cloudStatus: CloudStatusLoggedIn = {
|
||||
logged_in: true,
|
||||
cloud: "connected",
|
||||
cloud_last_disconnect_reason: null,
|
||||
email: "[email protected]",
|
||||
google_registered: true,
|
||||
google_registered: false,
|
||||
google_entities: emptyFilter(),
|
||||
google_domains: ["light", "switch", "climate", "cover"],
|
||||
alexa_registered: true,
|
||||
@@ -58,20 +69,20 @@ const cloudStatus: CloudStatusLoggedIn = {
|
||||
http_use_ssl: false,
|
||||
active_subscription: true,
|
||||
onboarding_postponed: false,
|
||||
onboarding_completed: true,
|
||||
onboarding_completed: false,
|
||||
prefs: {
|
||||
google_enabled: true,
|
||||
google_enabled: false,
|
||||
alexa_enabled: true,
|
||||
remote_enabled: true,
|
||||
remote_allow_remote_enable: true,
|
||||
strict_connection: "disabled",
|
||||
google_secure_devices_pin: undefined,
|
||||
cloudhooks: {},
|
||||
cloudhooks: demoCloudhooks,
|
||||
alexa_report_state: true,
|
||||
google_report_state: true,
|
||||
tts_default_voice: ["en-US", "JennyNeural"],
|
||||
cloud_ice_servers_enabled: true,
|
||||
onboarded_items: [...ONBOARDING_ITEMS],
|
||||
cloud_ice_servers_enabled: false,
|
||||
onboarded_items: [],
|
||||
onboarding_postponed_until: null,
|
||||
},
|
||||
};
|
||||
@@ -93,94 +104,6 @@ const ttsInfo: CloudTTSInfo = {
|
||||
],
|
||||
};
|
||||
|
||||
// Map the high-level demo scenario onto the mutable cloud status / subscription.
|
||||
const applyScenario = () => {
|
||||
const scenario = getCloudDemoScenario();
|
||||
|
||||
switch (scenario.account) {
|
||||
case "trialing":
|
||||
cloudStatus.active_subscription = true;
|
||||
subscription.subscription = { status: "trialing" };
|
||||
break;
|
||||
case "canceled":
|
||||
cloudStatus.active_subscription = false;
|
||||
subscription.subscription = { status: "canceled" };
|
||||
break;
|
||||
case "expired":
|
||||
cloudStatus.active_subscription = false;
|
||||
subscription.subscription = { status: "expired" };
|
||||
break;
|
||||
case "unknown":
|
||||
cloudStatus.active_subscription = true;
|
||||
subscription.subscription = { status: "unknown" };
|
||||
break;
|
||||
default:
|
||||
// "active"
|
||||
cloudStatus.active_subscription = true;
|
||||
subscription.subscription = { status: "active" };
|
||||
}
|
||||
|
||||
cloudStatus.prefs.onboarded_items = scenario.onboarded
|
||||
? [...ONBOARDING_ITEMS]
|
||||
: [];
|
||||
cloudStatus.onboarding_completed = scenario.onboarded;
|
||||
cloudStatus.onboarding_postponed = scenario.postponed;
|
||||
cloudStatus.prefs.onboarding_postponed_until = scenario.postponed
|
||||
? new Date(Date.now() + 24 * 3600 * 1000).toISOString()
|
||||
: null;
|
||||
cloudStatus.prefs.remote_enabled = scenario.remote;
|
||||
cloudStatus.remote_connected = scenario.remote;
|
||||
cloudStatus.remote_certificate_status = scenario.remoteStatus;
|
||||
cloudStatus.alexa_registered = scenario.alexa;
|
||||
cloudStatus.google_registered = scenario.google;
|
||||
cloudStatus.prefs.cloud_ice_servers_enabled = scenario.webrtc;
|
||||
|
||||
const hasCloudhooks = Object.keys(cloudStatus.prefs.cloudhooks).length > 0;
|
||||
if (scenario.webhooks && !hasCloudhooks) {
|
||||
cloudStatus.prefs.cloudhooks = Object.fromEntries(
|
||||
demoWebhooks.map((webhook) => [
|
||||
webhook.webhook_id,
|
||||
{
|
||||
webhook_id: webhook.webhook_id,
|
||||
cloudhook_id: `demo-${webhook.webhook_id}`,
|
||||
cloudhook_url: `https://hooks.nabu.casa/demo-${webhook.webhook_id}`,
|
||||
managed: false,
|
||||
},
|
||||
])
|
||||
);
|
||||
} else if (!scenario.webhooks && hasCloudhooks) {
|
||||
cloudStatus.prefs.cloudhooks = {};
|
||||
}
|
||||
};
|
||||
|
||||
applyScenario();
|
||||
subscribeCloudDemoScenario(applyScenario);
|
||||
|
||||
// Reflect UI-driven changes (onboarding toggles, remote connect/disconnect)
|
||||
// back into the demo scenario so the demo controls panel stays in sync with the
|
||||
// mocked state. Only writes when a value actually changed, to avoid needless
|
||||
// re-projection. `applyScenario` re-applies the (now matching) scenario, so
|
||||
// this stays idempotent and does not fight the direct mutation above.
|
||||
const syncScenarioFromStatus = () => {
|
||||
const scenario = getCloudDemoScenario();
|
||||
const next = {
|
||||
onboarded: cloudStatus.onboarding_completed,
|
||||
postponed: cloudStatus.onboarding_postponed,
|
||||
remote: cloudStatus.prefs.remote_enabled,
|
||||
webrtc: cloudStatus.prefs.cloud_ice_servers_enabled,
|
||||
webhooks: Object.keys(cloudStatus.prefs.cloudhooks).length > 0,
|
||||
};
|
||||
if (
|
||||
scenario.onboarded !== next.onboarded ||
|
||||
scenario.postponed !== next.postponed ||
|
||||
scenario.remote !== next.remote ||
|
||||
scenario.webrtc !== next.webrtc ||
|
||||
scenario.webhooks !== next.webhooks
|
||||
) {
|
||||
setCloudDemoScenario(next);
|
||||
}
|
||||
};
|
||||
|
||||
export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("cloud/status", () => ({
|
||||
...cloudStatus,
|
||||
@@ -193,7 +116,6 @@ export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("cloud/update_prefs", (msg) => {
|
||||
const { type, ...prefs } = msg;
|
||||
cloudStatus.prefs = { ...cloudStatus.prefs, ...prefs };
|
||||
syncScenarioFromStatus();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -202,7 +124,6 @@ export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
Date.now() + 24 * 3600 * 1000
|
||||
).toISOString();
|
||||
cloudStatus.onboarding_postponed = true;
|
||||
syncScenarioFromStatus();
|
||||
// Backend returns the full logged-in status object.
|
||||
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
|
||||
});
|
||||
@@ -222,7 +143,6 @@ export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
(onboardingItem) =>
|
||||
cloudStatus.prefs.onboarded_items.includes(onboardingItem)
|
||||
);
|
||||
syncScenarioFromStatus();
|
||||
// Backend returns the full logged-in status object.
|
||||
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
|
||||
});
|
||||
@@ -245,20 +165,17 @@ export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
const cloudhooks = { ...cloudStatus.prefs.cloudhooks };
|
||||
delete cloudhooks[msg.webhook_id];
|
||||
cloudStatus.prefs.cloudhooks = cloudhooks;
|
||||
syncScenarioFromStatus();
|
||||
return null;
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/remote/connect", () => {
|
||||
cloudStatus.remote_connected = true;
|
||||
cloudStatus.prefs.remote_enabled = true;
|
||||
syncScenarioFromStatus();
|
||||
return null;
|
||||
});
|
||||
hass.mockWS("cloud/remote/disconnect", () => {
|
||||
cloudStatus.remote_connected = false;
|
||||
cloudStatus.prefs.remote_enabled = false;
|
||||
syncScenarioFromStatus();
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
@@ -8,12 +8,14 @@ import { mockCloud } from "./cloud";
|
||||
import { mockConfig } from "./config";
|
||||
import { mockConfigEntries } from "./config_entries";
|
||||
import { mockDeviceAutomation } from "./device_automation";
|
||||
import { mockEntityRegistrySettings } from "./entity_registry_settings";
|
||||
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 { mockSlugify } from "./slugify";
|
||||
import { mockSystemHealth } from "./system_health";
|
||||
import { mockTags } from "./tags";
|
||||
import { mockZone } from "./zone";
|
||||
@@ -39,4 +41,6 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
|
||||
mockSearch(hass);
|
||||
mockTags(hass);
|
||||
mockAssist(hass);
|
||||
mockEntityRegistrySettings(hass);
|
||||
mockSlugify(hass);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ const baseDevice = {
|
||||
name_by_user: null,
|
||||
disabled_by: null,
|
||||
configuration_url: null,
|
||||
parent_device_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
@@ -50,4 +51,39 @@ export const demoDevices: DeviceRegistryEntry[] = [
|
||||
primary_config_entry: "mock-sonos",
|
||||
entry_type: null,
|
||||
},
|
||||
{
|
||||
...baseDevice,
|
||||
id: "power-strip",
|
||||
name: "Power strip",
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
config_entries: ["mock-hue"],
|
||||
primary_config_entry: "mock-hue",
|
||||
entry_type: null,
|
||||
},
|
||||
// Child devices (logical parts of the power strip). They carry the parent's
|
||||
// inherited hardware fields, mirroring how resolveChildDevices fills them in
|
||||
// from the WebSocket, and reference the parent via parent_device_id.
|
||||
{
|
||||
...baseDevice,
|
||||
id: "power-strip-outlet-1",
|
||||
name: "Outlet 1",
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
config_entries: ["mock-hue"],
|
||||
primary_config_entry: "mock-hue",
|
||||
entry_type: null,
|
||||
parent_device_id: "power-strip",
|
||||
},
|
||||
{
|
||||
...baseDevice,
|
||||
id: "power-strip-outlet-2",
|
||||
name: "Outlet 2",
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
config_entries: ["mock-hue"],
|
||||
primary_config_entry: "mock-hue",
|
||||
entry_type: null,
|
||||
parent_device_id: "power-strip",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -102,6 +102,11 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
|
||||
cost_sensors: {},
|
||||
solar_forecast_domains: [],
|
||||
}));
|
||||
hass.mockWS("energy/validate", () => ({
|
||||
energy_sources: Array.from({ length: 6 }, () => []),
|
||||
device_consumption: Array.from({ length: 6 }, () => []),
|
||||
device_consumption_water: Array.from({ length: 2 }, () => []),
|
||||
}));
|
||||
hass.mockWS(
|
||||
"energy/fossil_energy_consumption",
|
||||
({ period }): FossilEnergyConsumption => ({
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type {
|
||||
EntityRegistrySettings,
|
||||
fetchEntityRegistrySettings,
|
||||
updateEntityRegistrySettings,
|
||||
} from "../../../src/data/entity/entity_registry_settings";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockEntityRegistrySettings = (hass: MockHomeAssistant) => {
|
||||
let settings: EntityRegistrySettings = { entity_id_parts: null };
|
||||
|
||||
hass.mockWS<typeof fetchEntityRegistrySettings>(
|
||||
"config/entity_registry/settings/get",
|
||||
() => settings
|
||||
);
|
||||
hass.mockWS<typeof updateEntityRegistrySettings>(
|
||||
"config/entity_registry/settings/update",
|
||||
(msg: Partial<EntityRegistrySettings>) => {
|
||||
settings = { ...settings, ...msg };
|
||||
return settings;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockSensor = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"sensor/device_class_convertible_units",
|
||||
({ device_class }: { device_class: string }) => ({
|
||||
units: device_class === "energy" ? ["kWh"] : ["W"],
|
||||
})
|
||||
);
|
||||
hass.mockWS("sensor/numeric_device_classes", () => ({
|
||||
numeric_device_classes: [
|
||||
"volume_storage",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { slugify } from "../../../src/common/string/slugify";
|
||||
import type { fetchSlug } from "../../../src/data/ws-slugify";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockSlugify = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS<typeof fetchSlug>("slugify", (msg: { text: string }) => ({
|
||||
slug: slugify(msg.text),
|
||||
}));
|
||||
};
|
||||
+31
-1
@@ -17,6 +17,9 @@ const rspackConfigPath = fileURLToPath(
|
||||
new URL("./rspack.config.cjs", import.meta.url)
|
||||
);
|
||||
|
||||
// Applies everywhere, including the files exempted from the history rule below.
|
||||
const restrictedSyntax = ["LabeledStatement", "WithStatement"];
|
||||
|
||||
export default tseslint.config(
|
||||
js.configs.recommended,
|
||||
eslintConfigPrettier,
|
||||
@@ -111,7 +114,16 @@ export default tseslint.config(
|
||||
"no-bitwise": "error",
|
||||
"no-console": "error",
|
||||
"no-restricted-globals": [2, "event"],
|
||||
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
...restrictedSyntax,
|
||||
{
|
||||
selector:
|
||||
"CallExpression[callee.property.name=/^(push|replace)State$/]",
|
||||
message:
|
||||
"Use navigate(), updateHistoryState() or replaceCurrentUrl() from common/navigate. History entries carry the app's own bookkeeping, which a raw pushState/replaceState drops.",
|
||||
},
|
||||
],
|
||||
"wc/no-self-class": "off",
|
||||
|
||||
// import-x rules
|
||||
@@ -222,6 +234,24 @@ export default tseslint.config(
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// These own history entries themselves: the navigation helpers, the dialog
|
||||
// stack, the boot paths that run before the app has any state to keep, and
|
||||
// the tests that fabricate entries to simulate a document load.
|
||||
files: [
|
||||
"src/common/navigate.ts",
|
||||
"src/dialogs/make-dialog-manager.ts",
|
||||
"src/state/url-sync-mixin.ts",
|
||||
"src/panels/config/automation/add-automation-element-dialog.ts",
|
||||
"src/entrypoints/core.ts",
|
||||
"src/onboarding/**/*.ts",
|
||||
"cast/**/*.ts",
|
||||
"test/**/*.ts",
|
||||
],
|
||||
rules: {
|
||||
"no-restricted-syntax": ["error", ...restrictedSyntax],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/util/recorder-worklet.js"],
|
||||
languageOptions: {
|
||||
|
||||
@@ -228,6 +228,7 @@ export default [
|
||||
"entity-state",
|
||||
"ha-markdown",
|
||||
"integration-card",
|
||||
"cloud-account",
|
||||
"box-shadow",
|
||||
"util-long-press",
|
||||
"remove-delete-add-create",
|
||||
|
||||
@@ -37,6 +37,16 @@ title: Button
|
||||
<ha-button size="s"> small </ha-button>
|
||||
```
|
||||
|
||||
### Icons in the `xs` size
|
||||
|
||||
Avoid icons in `xs` buttons. At 24px the label carries the meaning on its own, and a
|
||||
16px glyph next to it adds visual noise without adding information.
|
||||
|
||||
Use an icon only when the button needs to be recognized at a glance in a dense layout,
|
||||
and only when the glyph is a common one users can identify from its silhouette alone,
|
||||
such as close, add, or settings. A detailed or unfamiliar glyph is unreadable at this
|
||||
size and should be replaced by the label alone.
|
||||
|
||||
### API
|
||||
|
||||
This component is based on the webawesome button component.
|
||||
|
||||
@@ -56,6 +56,19 @@ export class DemoHaButton extends LitElement {
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
${appearances.map(
|
||||
(appearance) => html`
|
||||
<ha-button
|
||||
.appearance=${appearance}
|
||||
.variant=${variant}
|
||||
size="xs"
|
||||
>
|
||||
${titleCase(`${variant} ${appearance}`)}
|
||||
</ha-button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
${appearances.map(
|
||||
(appearance) => html`
|
||||
|
||||
@@ -87,6 +87,7 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
{
|
||||
area_id: "backyard",
|
||||
@@ -111,6 +112,7 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
{
|
||||
area_id: null,
|
||||
@@ -135,6 +137,7 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -106,6 +106,17 @@ export class DemoHaSelectBox extends LitElement {
|
||||
</ha-card>
|
||||
`;
|
||||
})}
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<label>Disabled with a selected option</label>
|
||||
<ha-select-box
|
||||
.value=${"card"}
|
||||
.options=${fullOptions}
|
||||
.disabled=${true}
|
||||
>
|
||||
</ha-select-box>
|
||||
</div>
|
||||
</ha-card>
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<p class="title"><b>Column layout</b></p>
|
||||
|
||||
@@ -50,6 +50,7 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
{
|
||||
area_id: "backyard",
|
||||
@@ -74,6 +75,7 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
{
|
||||
area_id: "backyard",
|
||||
@@ -124,6 +125,7 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
{
|
||||
area_id: null,
|
||||
@@ -148,6 +150,85 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
{
|
||||
area_id: "livingroom",
|
||||
configuration_url: null,
|
||||
config_entries: ["config_entry_1"],
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
disabled_by: null,
|
||||
entry_type: null,
|
||||
id: "device_power_strip",
|
||||
identifiers: [["demo", "strip1"] as [string, string]],
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
model_id: null,
|
||||
name_by_user: null,
|
||||
name: "Power strip",
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
via_device_id: null,
|
||||
serial_number: null,
|
||||
labels: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
// Child devices of the power strip. They have no area of their own and
|
||||
// inherit the parent's area ("Livingroom"); the picker renders them indented
|
||||
// under the parent with a tree connector.
|
||||
{
|
||||
area_id: null,
|
||||
configuration_url: null,
|
||||
config_entries: ["config_entry_1"],
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
disabled_by: null,
|
||||
entry_type: null,
|
||||
id: "device_outlet_1",
|
||||
identifiers: [["demo", "outlet1"] as [string, string]],
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
model_id: null,
|
||||
name_by_user: null,
|
||||
name: "Outlet 1",
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
via_device_id: null,
|
||||
serial_number: null,
|
||||
labels: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: "device_power_strip",
|
||||
},
|
||||
{
|
||||
area_id: null,
|
||||
configuration_url: null,
|
||||
config_entries: ["config_entry_1"],
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
disabled_by: null,
|
||||
entry_type: null,
|
||||
id: "device_outlet_2",
|
||||
identifiers: [["demo", "outlet2"] as [string, string]],
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
model_id: null,
|
||||
name_by_user: null,
|
||||
name: "Outlet 2",
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
via_device_id: null,
|
||||
serial_number: null,
|
||||
labels: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: "device_power_strip",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -174,6 +174,14 @@ const CONFIGS = [
|
||||
entities: ["zone.bushfire"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Scale ruler",
|
||||
config: {
|
||||
type: "map",
|
||||
scale_ruler: true,
|
||||
entities: ["zone.home"],
|
||||
},
|
||||
},
|
||||
] satisfies DemoCardConfig<MapCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-map-card")
|
||||
|
||||
@@ -132,12 +132,12 @@ const ENTITIES = [
|
||||
fan_modes: ["on_low", "on_high", "auto_low", "auto_high", "off"],
|
||||
preset_modes: ["home", "eco", "away"],
|
||||
swing_modes: ["auto", "1", "2", "3", "off"],
|
||||
switch_horizontal_modes: ["auto", "4", "5", "6", "off"],
|
||||
current_temperature: 23,
|
||||
target_temp_high: 24,
|
||||
target_temp_low: 21,
|
||||
fan_mode: "auto_low",
|
||||
preset_mode: "home",
|
||||
swing_horizontal_modes: ["auto", "4", "5", "6", "off"],
|
||||
swing_mode: "auto",
|
||||
swing_horizontal_mode: "off",
|
||||
supported_features:
|
||||
@@ -340,6 +340,84 @@ const CONFIGS = [
|
||||
features: [{ type: "fan-oscillate" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: one feature",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [{ type: "climate-hvac-modes", style: "dropdown" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: two features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: three features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
{ type: "climate-fan-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: four features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
{ type: "climate-fan-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: five features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
{ type: "climate-fan-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Bottom features: five features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "bottom",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
{ type: "climate-fan-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
] satisfies DemoCardConfig<TileCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-tile-card")
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: Cloud account
|
||||
---
|
||||
|
||||
The [Home Assistant Cloud](https://www.nabucasa.com/) account page, rendered from
|
||||
mocked cloud data. The controls at the top flip the mocked subscription, remote,
|
||||
backup, onboarding, and feature state so every UI state can be previewed here
|
||||
without a real cloud account.
|
||||
@@ -0,0 +1,568 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import "../../../../src/components/ha-formfield";
|
||||
import "../../../../src/components/ha-select";
|
||||
import type { HaSelectSelectEvent } from "../../../../src/components/ha-select";
|
||||
import "../../../../src/components/ha-switch";
|
||||
import type { HaSwitch } from "../../../../src/components/ha-switch";
|
||||
import type { BackupConfig } from "../../../../src/data/backup";
|
||||
import { BackupScheduleRecurrence } from "../../../../src/data/backup";
|
||||
import type {
|
||||
CloudStatusLoggedIn,
|
||||
RemoteCertificateStatus,
|
||||
SubscriptionInfo,
|
||||
} from "../../../../src/data/cloud";
|
||||
import { ONBOARDING_ITEMS } from "../../../../src/data/cloud";
|
||||
import type { Webhook } from "../../../../src/data/webhook";
|
||||
import type { ShowDialogParams } from "../../../../src/dialogs/make-dialog-manager";
|
||||
import { showDialog } from "../../../../src/dialogs/make-dialog-manager";
|
||||
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { ProvideHassElement } from "../../../../src/mixins/provide-hass-lit-mixin";
|
||||
import "../../../../src/panels/config/cloud/account/cloud-account-onboarding";
|
||||
import "../../../../src/panels/config/cloud/account/cloud-account-overview";
|
||||
import { onboardingComplete } from "../../../../src/panels/config/cloud/account/cloud-account-status";
|
||||
import { showCloudOnboardingDialog } from "../../../../src/panels/config/cloud/account/show-dialog-cloud-onboarding";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
// This demo renders the self-contained cloud account cards (overview +
|
||||
// onboarding) directly, driven by mocked data, with controls to flip the
|
||||
// subscription, remote, backup, onboarding, and feature state so every UI state
|
||||
// can be previewed. It intentionally does NOT render the <cloud-account> panel
|
||||
// wrapper, which adds a hass-subpage toolbar/back button and route links that
|
||||
// have no home in the gallery. See src/panels/config/cloud/account.
|
||||
|
||||
// The five PaymentSubscriptionState values.
|
||||
type DemoCloudAccount =
|
||||
"active" | "trialing" | "canceled" | "expired" | "unknown";
|
||||
|
||||
// "local": automatic backups run, but only to the local agent (no cloud copy).
|
||||
// "none": no automatic backups at all.
|
||||
type DemoCloudBackup = "fresh" | "stale" | "failed" | "local" | "none";
|
||||
|
||||
interface CloudDemoScenario {
|
||||
account: DemoCloudAccount;
|
||||
onboarded: boolean;
|
||||
postponed: boolean;
|
||||
remote: boolean;
|
||||
remoteStatus: RemoteCertificateStatus;
|
||||
backup: DemoCloudBackup;
|
||||
alexa: boolean;
|
||||
google: boolean;
|
||||
webrtc: boolean;
|
||||
webhooks: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SCENARIO: CloudDemoScenario = {
|
||||
account: "active",
|
||||
// Onboarding not completed and streaming (WebRTC) left off, so the onboarding
|
||||
// card shows by default with streaming as the remaining step.
|
||||
onboarded: false,
|
||||
postponed: false,
|
||||
remote: true,
|
||||
remoteStatus: "ready",
|
||||
backup: "fresh",
|
||||
alexa: true,
|
||||
google: true,
|
||||
webrtc: false,
|
||||
webhooks: true,
|
||||
};
|
||||
|
||||
const SUBSCRIPTION_OPTIONS: { value: DemoCloudAccount; label: string }[] = [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "trialing", label: "Trialing" },
|
||||
{ value: "canceled", label: "Canceled" },
|
||||
{ value: "expired", label: "Expired" },
|
||||
{ value: "unknown", label: "Unknown" },
|
||||
];
|
||||
|
||||
const REMOTE_STATUS_OPTIONS: {
|
||||
value: RemoteCertificateStatus;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ value: "ready", label: "Ready" },
|
||||
{ value: "generating", label: "Preparing" },
|
||||
{ value: "loading", label: "Loading" },
|
||||
{ value: "loaded", label: "Loaded" },
|
||||
{ value: "error", label: "Error" },
|
||||
];
|
||||
|
||||
const BACKUP_OPTIONS: { value: DemoCloudBackup; label: string }[] = [
|
||||
{ value: "fresh", label: "Recent" },
|
||||
{ value: "stale", label: "Old" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
{ value: "local", label: "Local only" },
|
||||
{ value: "none", label: "None" },
|
||||
];
|
||||
|
||||
const TOGGLES: [keyof CloudDemoScenario, string][] = [
|
||||
["onboarded", "Onboarded"],
|
||||
["postponed", "Onboarding postponed"],
|
||||
["remote", "Remote access"],
|
||||
["alexa", "Alexa linked"],
|
||||
["google", "Google linked"],
|
||||
["webrtc", "Cameras (WebRTC)"],
|
||||
["webhooks", "Has webhooks"],
|
||||
];
|
||||
|
||||
const CLOUD_AGENT = "cloud.cloud";
|
||||
|
||||
const emptyFilter = () => ({
|
||||
include_domains: [],
|
||||
include_entities: [],
|
||||
exclude_domains: [],
|
||||
exclude_entities: [],
|
||||
});
|
||||
|
||||
const demoWebhooks: Webhook[] = [
|
||||
{
|
||||
webhook_id: "demo_front_door",
|
||||
domain: "automation",
|
||||
name: "Front door motion",
|
||||
local_only: false,
|
||||
},
|
||||
{
|
||||
webhook_id: "demo_companion_app",
|
||||
domain: "mobile_app",
|
||||
name: "Companion app",
|
||||
local_only: false,
|
||||
},
|
||||
];
|
||||
|
||||
const buildSubscription = (scenario: CloudDemoScenario): SubscriptionInfo => ({
|
||||
human_description: "Demo subscription, renews automatically",
|
||||
provider: "Nabu Casa, Inc.",
|
||||
plan_renewal_date: 4102444800,
|
||||
subscription: { status: scenario.account },
|
||||
});
|
||||
|
||||
const buildCloudStatus = (scenario: CloudDemoScenario): CloudStatusLoggedIn => {
|
||||
const active =
|
||||
scenario.account !== "canceled" && scenario.account !== "expired";
|
||||
const cloudhooks = scenario.webhooks
|
||||
? Object.fromEntries(
|
||||
demoWebhooks.map((webhook) => [
|
||||
webhook.webhook_id,
|
||||
{
|
||||
webhook_id: webhook.webhook_id,
|
||||
cloudhook_id: `demo-${webhook.webhook_id}`,
|
||||
cloudhook_url: `https://hooks.nabu.casa/demo-${webhook.webhook_id}`,
|
||||
managed: false,
|
||||
},
|
||||
])
|
||||
)
|
||||
: {};
|
||||
return {
|
||||
logged_in: true,
|
||||
cloud: "connected",
|
||||
cloud_last_disconnect_reason: null,
|
||||
email: "[email protected]",
|
||||
google_registered: scenario.google,
|
||||
google_entities: emptyFilter(),
|
||||
google_domains: ["light", "switch", "climate", "cover"],
|
||||
alexa_registered: scenario.alexa,
|
||||
alexa_entities: emptyFilter(),
|
||||
remote_domain: "demo-instance.ui.nabu.casa",
|
||||
remote_connected: scenario.remote,
|
||||
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: scenario.remoteStatus,
|
||||
http_use_ssl: false,
|
||||
active_subscription: active,
|
||||
onboarding_postponed: scenario.postponed,
|
||||
onboarding_completed: scenario.onboarded,
|
||||
prefs: {
|
||||
google_enabled: scenario.google,
|
||||
alexa_enabled: scenario.alexa,
|
||||
remote_enabled: scenario.remote,
|
||||
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: scenario.webrtc,
|
||||
onboarded_items: scenario.onboarded ? [...ONBOARDING_ITEMS] : [],
|
||||
onboarding_postponed_until: scenario.postponed
|
||||
? new Date(Date.now() + 24 * 3600 * 1000).toISOString()
|
||||
: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const buildBackupConfig = (scenario: CloudDemoScenario): BackupConfig => {
|
||||
const now = Date.now();
|
||||
const recent = new Date(now - 12 * 3600 * 1000).toISOString();
|
||||
const old = new Date(now - 5 * 86400000).toISOString();
|
||||
const future = new Date(now + 86400000).toISOString();
|
||||
const overdue = new Date(now - 6 * 3600 * 1000).toISOString();
|
||||
|
||||
const cloudEnabled =
|
||||
scenario.backup === "fresh" ||
|
||||
scenario.backup === "stale" ||
|
||||
scenario.backup === "failed";
|
||||
|
||||
let configured = true;
|
||||
let lastCompleted: string | null = null;
|
||||
let lastAttempted: string | null = null;
|
||||
let next: string | null = null;
|
||||
switch (scenario.backup) {
|
||||
case "fresh":
|
||||
case "local":
|
||||
lastCompleted = recent;
|
||||
lastAttempted = recent;
|
||||
next = future;
|
||||
break;
|
||||
case "stale":
|
||||
lastCompleted = old;
|
||||
lastAttempted = old;
|
||||
next = overdue;
|
||||
break;
|
||||
case "failed":
|
||||
lastCompleted = old;
|
||||
lastAttempted = recent;
|
||||
next = future;
|
||||
break;
|
||||
case "none":
|
||||
configured = false;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
automatic_backups_configured: configured,
|
||||
last_attempted_automatic_backup: lastAttempted,
|
||||
last_completed_automatic_backup: lastCompleted,
|
||||
next_automatic_backup: next,
|
||||
next_automatic_backup_additional: false,
|
||||
create_backup: {
|
||||
agent_ids: cloudEnabled
|
||||
? ["backup.local", CLOUD_AGENT]
|
||||
: ["backup.local"],
|
||||
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 },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@customElement("demo-misc-cloud-account")
|
||||
export class DemoMiscCloudAccount
|
||||
extends LitElement
|
||||
implements ProvideHassElement
|
||||
{
|
||||
@state() private hass!: HomeAssistant;
|
||||
|
||||
@state() private _scenario: CloudDemoScenario = { ...DEFAULT_SCENARIO };
|
||||
|
||||
@state() private _cloudStatus!: CloudStatusLoggedIn;
|
||||
|
||||
@state() private _subscription!: SubscriptionInfo;
|
||||
|
||||
@state() private _backupConfig!: BackupConfig;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
hass.updateTranslations("config", "en");
|
||||
|
||||
hass.updateHass({
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [
|
||||
...(hass.config?.components ?? []),
|
||||
"cloud",
|
||||
"backup",
|
||||
"webhook",
|
||||
],
|
||||
},
|
||||
});
|
||||
this._registerMocks(hass);
|
||||
this._applyScenario();
|
||||
}
|
||||
|
||||
public provideHass(el) {
|
||||
el.hass = this.hass;
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("show-dialog", this._showDialog);
|
||||
this.addEventListener("cloud-open-onboarding", this._openOnboarding);
|
||||
this.addEventListener("ha-refresh-cloud-status", this._refreshFromMocks);
|
||||
// The overview and onboarding dialog contain real <a href="/config/..">
|
||||
// links to panel routes that do not exist in the gallery. Keep them inert.
|
||||
this.addEventListener("click", this._neutralizeNavigation);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("show-dialog", this._showDialog);
|
||||
this.removeEventListener("cloud-open-onboarding", this._openOnboarding);
|
||||
this.removeEventListener("ha-refresh-cloud-status", this._refreshFromMocks);
|
||||
this.removeEventListener("click", this._neutralizeNavigation);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
const showOnboarding =
|
||||
this._cloudStatus.active_subscription &&
|
||||
!this._cloudStatus.onboarding_completed &&
|
||||
!this._cloudStatus.onboarding_postponed &&
|
||||
!onboardingComplete(this._cloudStatus, this._backupConfig);
|
||||
|
||||
return html`
|
||||
<div class="options">
|
||||
<div class="selects">
|
||||
${this._select("Subscription", "account", SUBSCRIPTION_OPTIONS)}
|
||||
${this._select("Remote status", "remoteStatus", REMOTE_STATUS_OPTIONS)}
|
||||
${this._select("Backups", "backup", BACKUP_OPTIONS)}
|
||||
</div>
|
||||
<div class="switches">
|
||||
${TOGGLES.map(([field, label]) => this._toggle(label, field))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview">
|
||||
${
|
||||
showOnboarding
|
||||
? html`
|
||||
<cloud-account-onboarding
|
||||
.hass=${this.hass}
|
||||
.cloudStatus=${this._cloudStatus}
|
||||
.backupConfig=${this._backupConfig}
|
||||
></cloud-account-onboarding>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<cloud-account-overview
|
||||
.hass=${this.hass}
|
||||
.cloudStatus=${this._cloudStatus}
|
||||
.subscription=${this._subscription}
|
||||
.backupConfig=${this._backupConfig}
|
||||
.webhooks=${demoWebhooks}
|
||||
></cloud-account-overview>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _select(
|
||||
label: string,
|
||||
field: keyof CloudDemoScenario,
|
||||
options: { value: string; label: string }[]
|
||||
) {
|
||||
return html`
|
||||
<ha-select
|
||||
.label=${label}
|
||||
.value=${String(this._scenario[field])}
|
||||
.options=${options}
|
||||
data-field=${field}
|
||||
@selected=${this._selectChanged}
|
||||
></ha-select>
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggle(label: string, field: keyof CloudDemoScenario) {
|
||||
return html`
|
||||
<ha-formfield .label=${label}>
|
||||
<ha-switch
|
||||
.checked=${this._scenario[field] as boolean}
|
||||
data-field=${field}
|
||||
@change=${this._switchChanged}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
`;
|
||||
}
|
||||
|
||||
private _selectChanged(ev: HaSelectSelectEvent) {
|
||||
const field = (ev.currentTarget as HTMLElement).dataset
|
||||
.field as keyof CloudDemoScenario;
|
||||
this._setField(field, ev.detail.value as string);
|
||||
}
|
||||
|
||||
private _switchChanged(ev: Event) {
|
||||
const target = ev.target as HaSwitch;
|
||||
this._setField(
|
||||
target.dataset.field as keyof CloudDemoScenario,
|
||||
target.checked
|
||||
);
|
||||
}
|
||||
|
||||
private _setField(field: keyof CloudDemoScenario, value: string | boolean) {
|
||||
if (this._scenario[field] === value) {
|
||||
return;
|
||||
}
|
||||
this._scenario = { ...this._scenario, [field]: value };
|
||||
this._applyScenario();
|
||||
}
|
||||
|
||||
private _applyScenario() {
|
||||
this._cloudStatus = buildCloudStatus(this._scenario);
|
||||
this._subscription = buildSubscription(this._scenario);
|
||||
this._backupConfig = buildBackupConfig(this._scenario);
|
||||
}
|
||||
|
||||
private _openOnboarding = () => {
|
||||
showCloudOnboardingDialog(this, {
|
||||
cloudStatus: this._cloudStatus,
|
||||
backupConfig: this._backupConfig,
|
||||
onChanged: () => this._refreshFromMocks(),
|
||||
});
|
||||
};
|
||||
|
||||
private _showDialog = (ev: HASSDomEvent<ShowDialogParams<unknown>>) => {
|
||||
const { dialogTag, dialogImport, dialogParams, addHistory, parentElement } =
|
||||
ev.detail;
|
||||
showDialog(
|
||||
this,
|
||||
dialogTag,
|
||||
dialogParams,
|
||||
dialogImport,
|
||||
parentElement,
|
||||
addHistory
|
||||
);
|
||||
};
|
||||
|
||||
private _refreshFromMocks = () => {
|
||||
this._cloudStatus = {
|
||||
...this._cloudStatus,
|
||||
prefs: { ...this._cloudStatus.prefs },
|
||||
};
|
||||
this._backupConfig = { ...this._backupConfig };
|
||||
const cloudBackup =
|
||||
this._backupConfig.create_backup.agent_ids.includes(CLOUD_AGENT);
|
||||
this._scenario = {
|
||||
...this._scenario,
|
||||
onboarded: this._cloudStatus.onboarding_completed,
|
||||
postponed: this._cloudStatus.onboarding_postponed,
|
||||
remote: this._cloudStatus.prefs.remote_enabled,
|
||||
webrtc: this._cloudStatus.prefs.cloud_ice_servers_enabled,
|
||||
backup: !this._backupConfig.automatic_backups_configured
|
||||
? "none"
|
||||
: cloudBackup
|
||||
? this._scenario.backup === "fresh" ||
|
||||
this._scenario.backup === "stale" ||
|
||||
this._scenario.backup === "failed"
|
||||
? this._scenario.backup
|
||||
: "fresh"
|
||||
: "local",
|
||||
};
|
||||
};
|
||||
|
||||
private _neutralizeNavigation = (ev: MouseEvent) => {
|
||||
const anchor = ev
|
||||
.composedPath()
|
||||
.find((el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement);
|
||||
if (anchor?.getAttribute("href")?.startsWith("/")) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
private _registerMocks(hass: MockHomeAssistant) {
|
||||
hass.mockWS("cloud/status", () => ({
|
||||
...this._cloudStatus,
|
||||
prefs: { ...this._cloudStatus.prefs },
|
||||
}));
|
||||
|
||||
hass.mockWS("cloud/update_prefs", (msg) => {
|
||||
const { type, ...prefs } = msg;
|
||||
this._cloudStatus.prefs = { ...this._cloudStatus.prefs, ...prefs };
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/onboarding/postpone", () => {
|
||||
this._cloudStatus.onboarding_postponed = true;
|
||||
this._cloudStatus.prefs.onboarding_postponed_until = new Date(
|
||||
Date.now() + 24 * 3600 * 1000
|
||||
).toISOString();
|
||||
return { ...this._cloudStatus, prefs: { ...this._cloudStatus.prefs } };
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/remote/connect", () => {
|
||||
this._cloudStatus.remote_connected = true;
|
||||
this._cloudStatus.prefs.remote_enabled = true;
|
||||
return null;
|
||||
});
|
||||
hass.mockWS("cloud/remote/disconnect", () => {
|
||||
this._cloudStatus.remote_connected = false;
|
||||
this._cloudStatus.prefs.remote_enabled = false;
|
||||
return null;
|
||||
});
|
||||
|
||||
hass.mockWS("backup/config/info", () => ({
|
||||
config: { ...this._backupConfig },
|
||||
}));
|
||||
hass.mockWS("backup/config/update", (msg) => {
|
||||
const { type, ...update } = msg;
|
||||
if (update.create_backup) {
|
||||
this._backupConfig.create_backup = {
|
||||
...this._backupConfig.create_backup,
|
||||
...update.create_backup,
|
||||
};
|
||||
}
|
||||
if (update.automatic_backups_configured !== undefined) {
|
||||
this._backupConfig.automatic_backups_configured =
|
||||
update.automatic_backups_configured;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.options {
|
||||
max-width: 600px;
|
||||
margin: 16px auto 0;
|
||||
padding: 0 16px 16px;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.selects {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.selects ha-select {
|
||||
min-width: 160px;
|
||||
flex: 1;
|
||||
}
|
||||
.switches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
}
|
||||
.preview {
|
||||
padding: 24px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-6, 24px);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-misc-cloud-account": DemoMiscCloudAccount;
|
||||
}
|
||||
}
|
||||
@@ -238,6 +238,7 @@ const createDeviceRegistryEntries = (
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ const ALL_FEATURES =
|
||||
VacuumEntityFeature.STOP +
|
||||
VacuumEntityFeature.RETURN_HOME +
|
||||
VacuumEntityFeature.FAN_SPEED +
|
||||
VacuumEntityFeature.BATTERY +
|
||||
VacuumEntityFeature.STATUS +
|
||||
VacuumEntityFeature.LOCATE +
|
||||
VacuumEntityFeature.CLEAN_SPOT +
|
||||
@@ -28,8 +27,6 @@ const ENTITIES = [
|
||||
attributes: {
|
||||
friendly_name: "Full featured vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 85,
|
||||
battery_icon: "mdi:battery-80",
|
||||
fan_speed: "balanced",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Charged",
|
||||
@@ -41,8 +38,6 @@ const ENTITIES = [
|
||||
attributes: {
|
||||
friendly_name: "Cleaning vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 62,
|
||||
battery_icon: "mdi:battery-60",
|
||||
fan_speed: "turbo",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Cleaning bedroom",
|
||||
@@ -58,10 +53,7 @@ const ENTITIES = [
|
||||
VacuumEntityFeature.START +
|
||||
VacuumEntityFeature.PAUSE +
|
||||
VacuumEntityFeature.STOP +
|
||||
VacuumEntityFeature.RETURN_HOME +
|
||||
VacuumEntityFeature.BATTERY,
|
||||
battery_level: 23,
|
||||
battery_icon: "mdi:battery-20",
|
||||
VacuumEntityFeature.RETURN_HOME,
|
||||
status: "Returning to dock",
|
||||
},
|
||||
},
|
||||
@@ -96,8 +88,6 @@ const ENTITIES = [
|
||||
attributes: {
|
||||
friendly_name: "Paused vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 45,
|
||||
battery_icon: "mdi:battery-40",
|
||||
fan_speed: "standard",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Paused",
|
||||
|
||||
+11
-12
@@ -49,7 +49,7 @@
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.7",
|
||||
"@codemirror/view": "6.43.8",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.6.0",
|
||||
@@ -93,7 +93,7 @@
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.1",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.49.0",
|
||||
"core-js": "3.50.0",
|
||||
"cropperjs": "1.6.2",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.4.0",
|
||||
@@ -103,7 +103,7 @@
|
||||
"echarts": "6.1.0",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.6.16",
|
||||
"hls.js": "1.7.0",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.13",
|
||||
@@ -114,7 +114,7 @@
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "18.0.7",
|
||||
"marked": "18.0.9",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -151,9 +151,9 @@
|
||||
"@octokit/plugin-retry": "8.1.1",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.1",
|
||||
"@rspack/core": "2.1.7",
|
||||
"@rspack/dev-server": "2.1.0",
|
||||
"@rsdoctor/rspack-plugin": "1.6.2",
|
||||
"@rspack/core": "2.1.10",
|
||||
"@rspack/dev-server": "2.2.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
@@ -164,7 +164,7 @@
|
||||
"@types/leaflet-draw": "1.0.13",
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
"@types/lodash.merge": "4.6.9",
|
||||
"@types/luxon": "3.7.3",
|
||||
"@types/luxon": "3.7.4",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
@@ -174,7 +174,7 @@
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.8.0",
|
||||
"eslint": "10.8.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
@@ -186,7 +186,7 @@
|
||||
"fs-extra": "11.4.0",
|
||||
"generate-license-file": "4.2.1",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.8.0",
|
||||
"globals": "17.11.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
@@ -210,7 +210,7 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.65.0",
|
||||
"typescript-eslint": "8.67.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.10",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
@@ -223,7 +223,6 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"globals": "17.8.0",
|
||||
"tslib": "2.8.1",
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
|
||||
},
|
||||
|
||||
@@ -39,6 +39,25 @@ const formatTimeWithSecondsMem = memoizeOne(
|
||||
})
|
||||
);
|
||||
|
||||
// 9:15:24.123 PM || 21:15:24,123
|
||||
export const formatTimeWithMilliseconds = (
|
||||
dateObj: Date,
|
||||
locale: FrontendLocaleData,
|
||||
config: HassConfig
|
||||
) => formatTimeWithMillisecondsMem(locale, config.time_zone).format(dateObj);
|
||||
|
||||
const formatTimeWithMillisecondsMem = memoizeOne(
|
||||
(locale: FrontendLocaleData, serverTimeZone: string) =>
|
||||
new Intl.DateTimeFormat(locale.language, {
|
||||
hour: useAmPm(locale) ? "numeric" : "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
fractionalSecondDigits: 3,
|
||||
hourCycle: useAmPm(locale) ? "h12" : "h23",
|
||||
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
|
||||
})
|
||||
);
|
||||
|
||||
// Tuesday 7:00 PM || Tuesday 19:00
|
||||
export const formatTimeWeekday = (
|
||||
dateObj: Date,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ReactiveElement } from "lit";
|
||||
import { getHistoryState, updateHistoryState } from "../navigate";
|
||||
import { throttle } from "../util/throttle";
|
||||
|
||||
const throttleReplaceState = throttle((value) => {
|
||||
history.replaceState({ scrollPosition: value }, "");
|
||||
updateHistoryState({ scrollPosition: value });
|
||||
}, 300);
|
||||
|
||||
export function restoreScroll(selector: string) {
|
||||
@@ -39,7 +40,8 @@ export function restoreScroll(selector: string) {
|
||||
newDescriptor = {
|
||||
get(this: ReactiveElement) {
|
||||
return (
|
||||
this[`__${String(propertyKey)}`] || history.state?.scrollPosition
|
||||
this[`__${String(propertyKey)}`] ||
|
||||
getHistoryState()?.scrollPosition
|
||||
);
|
||||
},
|
||||
set(this: ReactiveElement, value) {
|
||||
|
||||
@@ -23,24 +23,24 @@ export const isNavigationClick = (e: MouseEvent, preventDefault = true) => {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let href = anchor.href;
|
||||
if (!href || href.indexOf("mailto:") !== -1) {
|
||||
let url: URL;
|
||||
try {
|
||||
// anchor.href is always absolute; an empty or unparseable value throws.
|
||||
url = new URL(anchor.href);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const location = window.location;
|
||||
const origin = location.origin || location.protocol + "//" + location.host;
|
||||
if (!href.startsWith(origin)) {
|
||||
return undefined;
|
||||
}
|
||||
href = href.slice(origin.length);
|
||||
|
||||
if (href === "#") {
|
||||
// Only intercept same-origin links. A different scheme, host, or port is a
|
||||
// different origin (e.g. another port like ":8123") and must trigger a full
|
||||
// browser navigation instead of an in-app route change. Non-http(s) schemes
|
||||
// such as mailto: resolve to a null origin and are excluded here too.
|
||||
if (url.origin !== window.location.origin) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (preventDefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
return href;
|
||||
return url.pathname + url.search + url.hash;
|
||||
};
|
||||
|
||||
@@ -2,10 +2,31 @@ import type { AreaRegistryEntry } from "../../../data/area/area_registry";
|
||||
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
/**
|
||||
* Return the effective area id of a device: a child device without an area of
|
||||
* its own inherits its parent's area (mirrors core's
|
||||
* async_get_effective_area_id). Nesting is single-level, so no recursion.
|
||||
*/
|
||||
export const getDeviceAreaId = (
|
||||
device: DeviceRegistryEntry,
|
||||
devices: HomeAssistant["devices"]
|
||||
): string | undefined => {
|
||||
if (device.area_id) {
|
||||
return device.area_id;
|
||||
}
|
||||
if (device.parent_device_id) {
|
||||
return devices[device.parent_device_id]?.area_id ?? undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getDeviceArea = (
|
||||
device: DeviceRegistryEntry,
|
||||
areas: HomeAssistant["areas"]
|
||||
areas: HomeAssistant["areas"],
|
||||
// Required so every caller resolves a child device's effective area
|
||||
// consistently, see getDeviceAreaId.
|
||||
devices: HomeAssistant["devices"]
|
||||
): AreaRegistryEntry | undefined => {
|
||||
const areaId = device.area_id;
|
||||
const areaId = getDeviceAreaId(device, devices);
|
||||
return areaId ? areas[areaId] : undefined;
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from "../../../data/entity/entity_registry";
|
||||
import type { FloorRegistryEntry } from "../../../data/floor_registry";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { getDeviceAreaId } from "./get_device_context";
|
||||
|
||||
interface EntityContext {
|
||||
entity: EntityRegistryDisplayEntry | null;
|
||||
@@ -46,7 +47,11 @@ export const getEntityAreaId = (
|
||||
if (!entry) return undefined;
|
||||
const deviceId = entry.device_id;
|
||||
const device = deviceId ? devices[deviceId] : undefined;
|
||||
return entry.area_id || device?.area_id || undefined;
|
||||
return (
|
||||
entry.area_id ||
|
||||
(device ? getDeviceAreaId(device, devices) : undefined) ||
|
||||
undefined
|
||||
);
|
||||
};
|
||||
|
||||
export const getEntityEntryContext = (
|
||||
@@ -60,7 +65,8 @@ export const getEntityEntryContext = (
|
||||
const entity = entities[entry.entity_id];
|
||||
const deviceId = entry?.device_id;
|
||||
const device = deviceId ? devices[deviceId] : undefined;
|
||||
const areaId = entry?.area_id || device?.area_id;
|
||||
const areaId =
|
||||
entry?.area_id || (device ? getDeviceAreaId(device, devices) : undefined);
|
||||
const area = areaId ? areas[areaId] : undefined;
|
||||
const floorId = area?.floor_id;
|
||||
const floor = floorId ? floors[floorId] : undefined;
|
||||
|
||||
@@ -52,7 +52,7 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
|
||||
case "timer":
|
||||
return compareState === "active";
|
||||
case "camera":
|
||||
return compareState === "streaming";
|
||||
return ["streaming", "recording"].includes(compareState);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
+78
-41
@@ -1,6 +1,7 @@
|
||||
import { closeAllDialogs } from "../dialogs/make-dialog-manager";
|
||||
import { fireEvent } from "./dom/fire_event";
|
||||
import { mainWindow } from "./dom/get_main_window";
|
||||
import { currentPath } from "./url/current-path";
|
||||
|
||||
declare global {
|
||||
// for fire event
|
||||
@@ -11,12 +12,38 @@ declare global {
|
||||
|
||||
export interface NavigateOptions {
|
||||
replace?: boolean;
|
||||
data?: any;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// max time to wait for dialogs to close before navigating
|
||||
const DIALOG_WAIT_TIMEOUT = 500;
|
||||
|
||||
/**
|
||||
* State of the current history entry. Always read through this, the app writes
|
||||
* to the main window and a panel running in an iframe has its own history.
|
||||
*/
|
||||
export const getHistoryState = (): any => mainWindow.history.state;
|
||||
|
||||
/**
|
||||
* Merge into the current history entry's state, keeping what is already there.
|
||||
* Entries carry the app's own bookkeeping (`from`, `root`, dialog state), so
|
||||
* they must never be replaced wholesale.
|
||||
*/
|
||||
export const updateHistoryState = (patch: Record<string, unknown>) => {
|
||||
mainWindow.history.replaceState(
|
||||
{ ...mainWindow.history.state, ...patch },
|
||||
""
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Rewrite the URL of the current history entry without navigating and without
|
||||
* touching its state. For query parameter cleanup.
|
||||
*/
|
||||
export const replaceCurrentUrl = (url: string) => {
|
||||
mainWindow.history.replaceState(mainWindow.history.state, "", url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Stash a destination URL in the current history entry's state. If the page
|
||||
* is refreshed while a dialog is open, urlSyncMixin will navigate to this URL
|
||||
@@ -24,10 +51,7 @@ const DIALOG_WAIT_TIMEOUT = 500;
|
||||
* The current URL is not changed.
|
||||
*/
|
||||
export const setRefreshUrl = (path: string) => {
|
||||
mainWindow.history.replaceState(
|
||||
{ ...mainWindow.history.state, refreshUrl: path },
|
||||
""
|
||||
);
|
||||
updateHistoryState({ refreshUrl: path });
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -56,6 +80,17 @@ const ensureDialogsClosed = async (timestamp: number): Promise<boolean> => {
|
||||
return ensureDialogsClosed(timestamp);
|
||||
};
|
||||
|
||||
const buildHistoryState = (
|
||||
data: Record<string, unknown> | undefined,
|
||||
from?: string
|
||||
) => {
|
||||
const state = typeof data === "object" ? data : undefined;
|
||||
if (from === undefined) {
|
||||
return state ?? null;
|
||||
}
|
||||
return { ...state, from };
|
||||
};
|
||||
|
||||
export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
if (!canProceed) {
|
||||
@@ -63,37 +98,32 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
}
|
||||
const replace = options?.replace || false;
|
||||
|
||||
if (__DEMO__) {
|
||||
if (!path.includes("#")) {
|
||||
// The demo routes with the hash instead of the pathname. Resolve the
|
||||
// path like the browser would do for pushState, and keep the query
|
||||
// parameters in the URL query instead of inside the hash.
|
||||
const url = new URL(
|
||||
path,
|
||||
`${mainWindow.location.origin}${mainWindow.location.hash.substring(1)}`
|
||||
);
|
||||
path = `${mainWindow.location.pathname}${url.search}#${url.pathname}`;
|
||||
}
|
||||
if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
mainWindow.history.state?.root
|
||||
? { root: true }
|
||||
: (options?.data ?? null),
|
||||
"",
|
||||
path
|
||||
);
|
||||
} else {
|
||||
mainWindow.history.pushState(options?.data ?? null, "", path);
|
||||
}
|
||||
} else if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
mainWindow.history.state?.root ? { root: true } : (options?.data ?? null),
|
||||
if (__DEMO__ && !path.includes("#")) {
|
||||
// The demo routes with the hash instead of the pathname. Resolve the
|
||||
// path like the browser would do for pushState, and keep the query
|
||||
// parameters in the URL query instead of inside the hash.
|
||||
const url = new URL(
|
||||
path,
|
||||
`${mainWindow.location.origin}${mainWindow.location.hash.substring(1)}`
|
||||
);
|
||||
path = `${mainWindow.location.pathname}${url.search}#${url.pathname}`;
|
||||
}
|
||||
|
||||
const { history } = mainWindow;
|
||||
|
||||
if (replace) {
|
||||
// A replaced entry keeps its predecessor, so it keeps `from`.
|
||||
const { root, from } = history.state ?? {};
|
||||
const data = root ? { root: true } : options?.data;
|
||||
history.replaceState(buildHistoryState(data, from), "", path);
|
||||
} else {
|
||||
history.pushState(
|
||||
buildHistoryState(options?.data, currentPath()),
|
||||
"",
|
||||
path
|
||||
);
|
||||
} else {
|
||||
mainWindow.history.pushState(options?.data ?? null, "", path);
|
||||
}
|
||||
|
||||
fireEvent(mainWindow, "location-changed", {
|
||||
replace,
|
||||
});
|
||||
@@ -101,8 +131,17 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate back in history, with fallback to a default path if no history exists.
|
||||
* This prevents a user from getting stuck when they navigate directly to a page with no history.
|
||||
* Whether the previous history entry is a page this app navigated away from.
|
||||
* `history.length` cannot answer this: a login redirect goes through
|
||||
* `location.assign`, which leaves /auth/authorize right behind the requested
|
||||
* page, and going back there would bounce the user out of the app.
|
||||
*/
|
||||
export const canGoBack = (): boolean =>
|
||||
mainWindow.history.state?.from !== undefined;
|
||||
|
||||
/**
|
||||
* Navigate back to the page we came from, falling back to a path when the
|
||||
* previous entry is not ours (deep link, login redirect, fresh tab).
|
||||
*/
|
||||
export const goBack = async (fallbackPath?: string): Promise<void> => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
@@ -110,14 +149,12 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have history to go back to
|
||||
const { history } = mainWindow;
|
||||
if (history.length > 1) {
|
||||
history.back();
|
||||
// Read after closing dialogs: their history entries are popped by then, so
|
||||
// this is the state of the page entry.
|
||||
if (canGoBack()) {
|
||||
mainWindow.history.back();
|
||||
return;
|
||||
}
|
||||
|
||||
// No history available, navigate to fallback path
|
||||
const fallback = fallbackPath || "/";
|
||||
navigate(fallback, { replace: true });
|
||||
await navigate(fallbackPath || "/", { replace: true });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// RFC 1123 hostname label: 1-63 chars, alphanumeric, with hyphens allowed
|
||||
// only between the first and last character. The hyphen is escaped because a
|
||||
// `pattern` attribute is compiled with the `v` flag, under which a trailing
|
||||
// unescaped hyphen is an invalid character class — and a pattern that fails to
|
||||
// compile is silently ignored, disabling validation altogether.
|
||||
const LABEL = "[a-zA-Z0-9](?:[a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?";
|
||||
|
||||
// Hostname such as "localhost" or "homeassistant.lan", as dot-separated
|
||||
// labels. Unanchored, for use as an HTML `pattern` attribute (the browser
|
||||
// anchors it as `^(?:…)$`). The final label may not be all digits, so a
|
||||
// mistyped IP address like "300.1.1.1" is rejected rather than accepted as a
|
||||
// hostname. Deliberately excludes underscores and a trailing dot.
|
||||
export const HOSTNAME_PATTERN = `(?:${LABEL}\\.)*(?!\\d+$)${LABEL}`;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { mainWindow } from "../dom/get_main_window";
|
||||
|
||||
/**
|
||||
* The path of the page currently shown by the app. The demo routes with the
|
||||
* hash instead of the pathname, see navigate().
|
||||
*/
|
||||
export const currentPath = (): string =>
|
||||
__DEMO__
|
||||
? mainWindow.location.hash.substring(1)
|
||||
: mainWindow.location.pathname;
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
isMoreInfoView,
|
||||
type MoreInfoView,
|
||||
} from "../../dialogs/more-info/more-info-view";
|
||||
import type { SearchParamsSource } from "./query-params";
|
||||
|
||||
const ENTITY_ID_PARAM = "more-info-entity-id";
|
||||
const VIEW_PARAM = "more-info-view";
|
||||
|
||||
export interface MoreInfoUrlData {
|
||||
entityId?: string;
|
||||
view?: MoreInfoView;
|
||||
hash: URLSearchParams;
|
||||
}
|
||||
|
||||
export interface CreateMoreInfoUrlData {
|
||||
entityId: string;
|
||||
view: MoreInfoView;
|
||||
hash?: URLSearchParams;
|
||||
}
|
||||
|
||||
export const decodeMoreInfoUrl = (
|
||||
search: SearchParamsSource,
|
||||
hash = ""
|
||||
): MoreInfoUrlData => {
|
||||
const params =
|
||||
typeof search === "string"
|
||||
? new URLSearchParams(search)
|
||||
: search instanceof URLSearchParams
|
||||
? search
|
||||
: new URLSearchParams(search);
|
||||
const entityId = params.get(ENTITY_ID_PARAM) || undefined;
|
||||
const view = params.get(VIEW_PARAM) || undefined;
|
||||
|
||||
return {
|
||||
entityId,
|
||||
view: isMoreInfoView(view) ? view : undefined,
|
||||
hash: new URLSearchParams(
|
||||
__DEMO__ ? "" : hash.startsWith("#") ? hash.substring(1) : hash
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const createMoreInfoUrl = (
|
||||
base: string,
|
||||
data: CreateMoreInfoUrlData
|
||||
): string => {
|
||||
const url = new URL(base, window.location.origin);
|
||||
url.searchParams.set(ENTITY_ID_PARAM, data.entityId);
|
||||
url.searchParams.set(VIEW_PARAM, data.view);
|
||||
if (!__DEMO__ && data.hash !== undefined) {
|
||||
url.hash = data.hash.toString();
|
||||
}
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
};
|
||||
|
||||
export const removeMoreInfoUrl = (base: string): string => {
|
||||
const url = new URL(base, window.location.origin);
|
||||
url.searchParams.delete(ENTITY_ID_PARAM);
|
||||
url.searchParams.delete(VIEW_PARAM);
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
};
|
||||
@@ -11,13 +11,23 @@
|
||||
export const preserveUnchangedRecord = <T>(
|
||||
previous: Record<string, T> | undefined,
|
||||
next: Record<string, T>,
|
||||
equal: (a: T, b: T) => boolean
|
||||
equal: (a: T, b: T) => boolean,
|
||||
compareOrder = false
|
||||
): 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 previousKeys = Object.keys(previous);
|
||||
const nextKeys = Object.keys(next);
|
||||
|
||||
let changed = previousKeys.length !== nextKeys.length;
|
||||
|
||||
if (!changed && compareOrder) {
|
||||
changed = previousKeys.some((key, index) => key !== nextKeys[index]);
|
||||
}
|
||||
|
||||
for (const key of nextKeys) {
|
||||
const previousItem = previous[key];
|
||||
if (previousItem !== undefined && equal(previousItem, next[key])) {
|
||||
next[key] = previousItem;
|
||||
|
||||
@@ -18,6 +18,37 @@ interface MinMaxFrame {
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
const SECOND = 1000;
|
||||
const MINUTE = 60 * SECOND;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
// Frame sizes that divide the clock evenly. Frames are placed on absolute time
|
||||
// rather than relative to the window, so charts that follow "now" keep picking
|
||||
// the same points every redraw instead of redrawing with a different shape.
|
||||
const FRAME_SIZES = [
|
||||
[1, 2, 3, 5, 10, 20, 30, 50, 100, 200, 300, 500],
|
||||
[1, 2, 3, 5, 10, 15, 20, 30].map((n) => n * SECOND),
|
||||
[1, 2, 3, 5, 10, 15, 20, 30].map((n) => n * MINUTE),
|
||||
[1, 2, 3, 4, 6, 8, 12].map((n) => n * HOUR),
|
||||
[DAY],
|
||||
].flat();
|
||||
|
||||
// Always rounds down, so no chart ends up with fewer frames than it asked for.
|
||||
function snapFrameSize(step: number): number {
|
||||
if (step >= DAY) {
|
||||
return Math.floor(step / DAY) * DAY;
|
||||
}
|
||||
let snapped = FRAME_SIZES[0];
|
||||
for (const size of FRAME_SIZES) {
|
||||
if (size > step) {
|
||||
break;
|
||||
}
|
||||
snapped = size;
|
||||
}
|
||||
return snapped;
|
||||
}
|
||||
|
||||
export function downSampleLineData<
|
||||
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
|
||||
>(
|
||||
@@ -35,11 +66,13 @@ export function downSampleLineData<
|
||||
}
|
||||
const min = minX ?? getPointData(data[0]!)[0];
|
||||
const max = maxX ?? getPointData(data[data.length - 1]!)[0];
|
||||
const step = Math.ceil((max - min) / Math.floor(maxDetails));
|
||||
if (!Number.isFinite(step) || step <= 0) {
|
||||
const rawStep = Math.ceil((max - min) / Math.floor(maxDetails));
|
||||
if (!Number.isFinite(rawStep) || rawStep <= 0) {
|
||||
// a degenerate frame size would put every point in a single frame
|
||||
return data;
|
||||
}
|
||||
// snapped after the guard above, which relies on the unsnapped value
|
||||
const step = snapFrameSize(rawStep);
|
||||
|
||||
if (useMean) {
|
||||
// Group points into frames, accumulating sums in insertion order.
|
||||
@@ -52,7 +85,7 @@ export function downSampleLineData<
|
||||
const y = Number(pointData[1]);
|
||||
if (isNaN(x) || isNaN(y)) continue;
|
||||
|
||||
const frameIndex = Math.floor((x - min) / step);
|
||||
const frameIndex = Math.floor(x / step);
|
||||
const frame = frames.get(frameIndex);
|
||||
if (!frame) {
|
||||
frames.set(frameIndex, {
|
||||
@@ -90,7 +123,7 @@ export function downSampleLineData<
|
||||
const y = Number(pointData[1]);
|
||||
if (isNaN(x) || isNaN(y)) continue;
|
||||
|
||||
const frameIndex = Math.floor((x - min) / step);
|
||||
const frameIndex = Math.floor(x / step);
|
||||
const frame = frames.get(frameIndex);
|
||||
if (!frame) {
|
||||
frames.set(frameIndex, {
|
||||
|
||||
@@ -117,7 +117,7 @@ export class HaDataTable extends LitElement {
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@property({ type: Object }) public columns: DataTableColumnContainer = {};
|
||||
|
||||
@@ -1158,6 +1158,11 @@ export class HaDataTable extends LitElement {
|
||||
/* default mdc styles, colors changed, without checkbox styles */
|
||||
:host {
|
||||
height: 100%;
|
||||
--_cell-padding-inline: 16px;
|
||||
}
|
||||
|
||||
:host([narrow]) {
|
||||
--_cell-padding-inline: 8px;
|
||||
}
|
||||
.mdc-data-table__content {
|
||||
font-family: var(--ha-font-family-body);
|
||||
@@ -1238,8 +1243,7 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
.mdc-data-table__cell,
|
||||
.mdc-data-table__header-cell {
|
||||
padding-right: 16px;
|
||||
padding-left: 16px;
|
||||
padding-inline: var(--_cell-padding-inline);
|
||||
min-width: 150px;
|
||||
align-self: center;
|
||||
overflow: hidden;
|
||||
@@ -1259,14 +1263,8 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
.mdc-data-table__header-cell--checkbox,
|
||||
.mdc-data-table__cell--checkbox {
|
||||
/* @noflip */
|
||||
padding-left: 16px;
|
||||
/* @noflip */
|
||||
padding-right: 0;
|
||||
/* @noflip */
|
||||
padding-inline-start: 16px;
|
||||
/* @noflip */
|
||||
padding-inline-end: initial;
|
||||
padding-inline-start: var(--_cell-padding-inline);
|
||||
padding-inline-end: 0;
|
||||
width: 60px;
|
||||
min-width: 60px;
|
||||
}
|
||||
@@ -1379,8 +1377,7 @@ export class HaDataTable extends LitElement {
|
||||
.mdc-data-table__header-cell--overflow-menu:first-child,
|
||||
.mdc-data-table__header-cell--icon-button:first-child,
|
||||
.mdc-data-table__cell--icon-button:first-child {
|
||||
padding-left: 16px;
|
||||
padding-inline-start: 16px;
|
||||
padding-inline-start: var(--_cell-padding-inline);
|
||||
padding-inline-end: initial;
|
||||
}
|
||||
|
||||
@@ -1388,8 +1385,7 @@ export class HaDataTable extends LitElement {
|
||||
.mdc-data-table__header-cell--overflow-menu:last-child,
|
||||
.mdc-data-table__header-cell--icon-button:last-child,
|
||||
.mdc-data-table__cell--icon-button:last-child {
|
||||
padding-right: 16px;
|
||||
padding-inline-end: 16px;
|
||||
padding-inline-end: var(--_cell-padding-inline);
|
||||
padding-inline-start: initial;
|
||||
}
|
||||
.mdc-data-table__cell--overflow-menu,
|
||||
@@ -1516,11 +1512,17 @@ export class HaDataTable extends LitElement {
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
.primary {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.secondary {
|
||||
color: var(--secondary-text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.scroller {
|
||||
height: calc(100% - 57px);
|
||||
|
||||
@@ -8,6 +8,7 @@ export const datePickerStyles = css`
|
||||
}
|
||||
calendar-date::part(button),
|
||||
calendar-range::part(button) {
|
||||
color: var(--primary-text-color);
|
||||
border: none;
|
||||
background-color: unset;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
|
||||
@@ -73,7 +73,7 @@ export class DialogDeviceReplaced
|
||||
) =>
|
||||
candidates.map((deviceId) => {
|
||||
const device = devices[deviceId];
|
||||
const area = device ? getDeviceArea(device, areas) : undefined;
|
||||
const area = device ? getDeviceArea(device, areas, devices) : undefined;
|
||||
const configEntry = device?.primary_config_entry
|
||||
? configEntryLookup?.[device.primary_config_entry]
|
||||
: undefined;
|
||||
|
||||
@@ -3,11 +3,13 @@ import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { computeDeviceName } from "../../common/entity/compute_device_name";
|
||||
import { getDeviceArea } from "../../common/entity/context/get_device_context";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
|
||||
import {
|
||||
deviceComboBoxKeys,
|
||||
@@ -26,7 +28,9 @@ import "../ha-alert";
|
||||
import "../ha-button";
|
||||
import "../ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box";
|
||||
import "../ha-svg-icon";
|
||||
import "../ha-tree-indicator";
|
||||
import { showDeviceReplacedDialog } from "./show-dialog-device-replaced";
|
||||
|
||||
export type HaDevicePickerDeviceFilterFunc = (
|
||||
@@ -128,6 +132,7 @@ export class HaDevicePicker extends LitElement {
|
||||
entityFilter,
|
||||
excludeDevices,
|
||||
value,
|
||||
nested: true,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -216,6 +221,34 @@ export class HaDevicePicker extends LitElement {
|
||||
this.value
|
||||
);
|
||||
|
||||
// The fuzzy search ranks matches by relevance, which would pull a child device
|
||||
// above its parent (the parent often only matches through the lower-weighted
|
||||
// child names). Restore the nested order from the full item list and recompute
|
||||
// which child is last, so the tree connectors stay correct while searching.
|
||||
private _searchFn: PickerComboBoxSearchFn<DevicePickerItem> = (
|
||||
_search,
|
||||
filteredItems,
|
||||
allItems
|
||||
) => {
|
||||
const matchedIds = new Set(filteredItems.map((item) => item.id));
|
||||
const ordered = allItems.filter((item) => matchedIds.has(item.id));
|
||||
// Keep any items the search added that are not part of the nested list
|
||||
// (for example the "no items available" placeholder or additional items).
|
||||
const orderedIds = new Set(ordered.map((item) => item.id));
|
||||
const extras = filteredItems.filter((item) => !orderedIds.has(item.id));
|
||||
|
||||
return [
|
||||
...ordered.map((item, index) => {
|
||||
if (!item.is_child) {
|
||||
return item;
|
||||
}
|
||||
const nextItem = ordered[index + 1];
|
||||
return { ...item, last: !nextItem || !nextItem.is_child };
|
||||
}),
|
||||
...extras,
|
||||
];
|
||||
};
|
||||
|
||||
private _valueRenderer = memoizeOne(
|
||||
(
|
||||
configEntriesLookup: Record<string, ConfigEntry>,
|
||||
@@ -242,7 +275,7 @@ export class HaDevicePicker extends LitElement {
|
||||
return html`<span slot="headline">${deviceId}</span>`;
|
||||
}
|
||||
|
||||
const area = getDeviceArea(device, this.hass.areas);
|
||||
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
|
||||
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
@@ -279,46 +312,76 @@ export class HaDevicePicker extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html`
|
||||
<ha-combo-box-item type="button">
|
||||
${
|
||||
item.domain
|
||||
? html`
|
||||
<img
|
||||
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => {
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
return html`
|
||||
<ha-combo-box-item
|
||||
type="button"
|
||||
style=${
|
||||
item.is_child
|
||||
? "--md-list-item-leading-space: var(--ha-space-12);"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
${
|
||||
item.is_child
|
||||
? html`<ha-tree-indicator
|
||||
style=${styleMap({
|
||||
width: "var(--ha-space-12)",
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
height: "100%",
|
||||
left: rtl ? undefined : "var(--ha-space-1)",
|
||||
right: rtl ? "var(--ha-space-1)" : undefined,
|
||||
transform: rtl ? "scaleX(-1)" : "",
|
||||
})}
|
||||
.end=${item.last}
|
||||
slot="start"
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: item.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
></ha-tree-indicator>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
item.domain
|
||||
? html`
|
||||
<img
|
||||
slot="start"
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: item.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
|
||||
<span slot="headline">${item.primary}</span>
|
||||
${
|
||||
item.secondary
|
||||
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
item.domain_name
|
||||
? html`
|
||||
<div slot="trailing-supporting-text" class="domain">
|
||||
${item.domain_name}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
<span slot="headline">${item.primary}</span>
|
||||
${
|
||||
item.secondary
|
||||
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
item.domain_name
|
||||
? html`
|
||||
<div slot="trailing-supporting-text" class="domain">
|
||||
${item.domain_name}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
};
|
||||
|
||||
protected render() {
|
||||
const placeholder =
|
||||
@@ -375,6 +438,8 @@ export class HaDevicePicker extends LitElement {
|
||||
.value=${this.value}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.getItems=${this._getItems}
|
||||
.searchFn=${this._searchFn}
|
||||
no-sort
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
.valueRenderer=${valueRenderer}
|
||||
.searchKeys=${deviceComboBoxKeys}
|
||||
|
||||
@@ -34,6 +34,8 @@ export class HaButtonToggleGroup extends LitElement {
|
||||
@property({ type: Boolean, reflect: true, attribute: "full-width" })
|
||||
public fullWidth = false;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property() public variant:
|
||||
"brand" | "neutral" | "success" | "warning" | "danger" = "brand";
|
||||
|
||||
@@ -57,6 +59,7 @@ export class HaButtonToggleGroup extends LitElement {
|
||||
.value=${button.value}
|
||||
@click=${this._handleClick}
|
||||
.title=${button.label}
|
||||
.disabled=${this.disabled}
|
||||
.appearance=${this.active === button.value ? "accent" : "filled"}
|
||||
>
|
||||
${
|
||||
|
||||
@@ -65,6 +65,21 @@ export class HaButton extends Button {
|
||||
box-shadow: var(--ha-button-box-shadow);
|
||||
}
|
||||
|
||||
:host([size="xs"]) .button {
|
||||
--wa-form-control-height: var(
|
||||
--ha-button-height,
|
||||
var(--button-height, 24px)
|
||||
);
|
||||
font-size: var(--ha-font-size-m);
|
||||
--wa-form-control-padding-inline: var(--ha-space-2);
|
||||
}
|
||||
|
||||
/* A default 24px icon would fill the whole xs button. */
|
||||
:host([size="xs"]) slot[name="start"]::slotted(*),
|
||||
:host([size="xs"]) slot[name="end"]::slotted(*) {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
|
||||
:host([size="s"]) .button {
|
||||
--wa-form-control-height: var(
|
||||
--ha-button-height,
|
||||
|
||||
@@ -143,7 +143,8 @@ export class HaControlSelect extends LitElement {
|
||||
? repeat(
|
||||
this.options,
|
||||
(option) => option.value,
|
||||
(option) => this._renderOption(option)
|
||||
(option, index) =>
|
||||
this._renderOption(option, index === this._tabbableIndex)
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
@@ -151,7 +152,14 @@ export class HaControlSelect extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderOption(option: ControlSelectOption) {
|
||||
/* a radio group with no selection puts its first option in the tab sequence */
|
||||
private get _tabbableIndex() {
|
||||
const selectedIndex =
|
||||
this.options?.findIndex((option) => option.value === this.value) ?? -1;
|
||||
return selectedIndex === -1 ? 0 : selectedIndex;
|
||||
}
|
||||
|
||||
private _renderOption(option: ControlSelectOption, tabbable: boolean) {
|
||||
const isSelected = this.value === option.value;
|
||||
|
||||
return html`
|
||||
@@ -162,7 +170,7 @@ export class HaControlSelect extends LitElement {
|
||||
selected: isSelected,
|
||||
})}
|
||||
role="radio"
|
||||
tabindex=${isSelected ? "0" : "-1"}
|
||||
tabindex=${tabbable ? "0" : "-1"}
|
||||
.value=${option.value}
|
||||
aria-checked=${isSelected ? "true" : "false"}
|
||||
aria-label=${ifDefined(option.ariaLabel ?? option.label)}
|
||||
|
||||
@@ -68,6 +68,7 @@ export class HaDurationInput extends LitElement {
|
||||
{ label: "-", iconPath: mdiMinusThick, value: "-" },
|
||||
]}
|
||||
.active=${this._negative ? "-" : "+"}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._negativeChanged}
|
||||
></ha-button-toggle-group>
|
||||
`
|
||||
@@ -235,8 +236,8 @@ export class HaDurationInput extends LitElement {
|
||||
ev.stopPropagation();
|
||||
const negative = (ev.detail?.value || ev.target.value) === "-";
|
||||
this._toggleNegative = negative;
|
||||
const value = this.data;
|
||||
if (value) {
|
||||
if (this.data) {
|
||||
const value = { ...this.data };
|
||||
FIELDS.forEach((t) => {
|
||||
if (value[t]) {
|
||||
value[t] = negative ? -Math.abs(value[t]) : Math.abs(value[t]);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
|
||||
import type { Selector } from "../../data/selector";
|
||||
|
||||
/**
|
||||
* Value a selector already displays when no field value is set.
|
||||
* Used when enabling an optional service/trigger/condition field.
|
||||
*/
|
||||
export const getSelectorFallbackValue = (selector: Selector): unknown => {
|
||||
if ("constant" in selector) {
|
||||
return selector.constant?.value;
|
||||
}
|
||||
if ("boolean" in selector) {
|
||||
return false;
|
||||
}
|
||||
if ("number" in selector) {
|
||||
return selector.number?.min ?? 0;
|
||||
}
|
||||
if ("color_temp" in selector) {
|
||||
if (selector.color_temp?.unit === "kelvin") {
|
||||
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
|
||||
}
|
||||
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -18,6 +18,8 @@ type HlsLite = Omit<
|
||||
"subtitleTrackController" | "audioTrackController" | "emeController"
|
||||
>;
|
||||
|
||||
const HIDDEN_CLEANUP_DELAY = 60000;
|
||||
|
||||
@customElement("ha-hls-player")
|
||||
class HaHLSPlayer extends LitElement {
|
||||
@state()
|
||||
@@ -76,13 +78,22 @@ class HaHLSPlayer extends LitElement {
|
||||
|
||||
private static streamCount = 0;
|
||||
|
||||
private _hiddenCleanupTimeout?: number;
|
||||
|
||||
private _handleVisibilityChange = () => {
|
||||
if (document.pictureInPictureElement) {
|
||||
// video is playing in picture-in-picture mode, don't do anything
|
||||
return;
|
||||
}
|
||||
if (document.hidden) {
|
||||
this._cleanUp();
|
||||
this._hiddenCleanupTimeout = window.setTimeout(() => {
|
||||
this._hiddenCleanupTimeout = undefined;
|
||||
this._cleanUp();
|
||||
}, HIDDEN_CLEANUP_DELAY);
|
||||
} else if (this._hiddenCleanupTimeout) {
|
||||
// stream was not cleaned up yet, just cancel the cleanup
|
||||
clearTimeout(this._hiddenCleanupTimeout);
|
||||
this._hiddenCleanupTimeout = undefined;
|
||||
} else {
|
||||
this._resetError();
|
||||
this._startHls();
|
||||
@@ -105,6 +116,8 @@ class HaHLSPlayer extends LitElement {
|
||||
"visibilitychange",
|
||||
this._handleVisibilityChange
|
||||
);
|
||||
clearTimeout(this._hiddenCleanupTimeout);
|
||||
this._hiddenCleanupTimeout = undefined;
|
||||
HaHLSPlayer.streamCount -= 1;
|
||||
this._cleanUp();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,89 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { animate } from "@lit-labs/motion";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
|
||||
const THUMB_SIZE = 40;
|
||||
|
||||
@customElement("ha-icon-button-group")
|
||||
export class HaIconButtonGroup extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`<slot></slot>`;
|
||||
@state() private _thumbX = 0;
|
||||
|
||||
@state() private _thumbVisible = false;
|
||||
|
||||
@state() private _thumbBorderOnly = false;
|
||||
|
||||
// When the thumb appears, only fade it in at its new position instead of
|
||||
// also sliding it from wherever it was last visible.
|
||||
private _thumbAppearing = false;
|
||||
|
||||
private _observer = new MutationObserver(() => this._updateThumb());
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._observer.disconnect();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div
|
||||
class="thumb ${classMap({
|
||||
visible: this._thumbVisible,
|
||||
"border-only": this._thumbBorderOnly,
|
||||
})}"
|
||||
style=${styleMap({ left: `${this._thumbX}px` })}
|
||||
${animate(() => ({
|
||||
properties: this._thumbAppearing ? ["opacity"] : ["left", "opacity"],
|
||||
keyframeOptions: {
|
||||
duration: this._animationDuration(),
|
||||
easing: "ease-in-out",
|
||||
},
|
||||
skipInitial: true,
|
||||
}))}
|
||||
></div>
|
||||
<slot @slotchange=${this._handleSlotchange}></slot>
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated() {
|
||||
this._thumbAppearing = false;
|
||||
}
|
||||
|
||||
private _animationDuration(): number {
|
||||
return (
|
||||
parseFloat(
|
||||
getComputedStyle(this).getPropertyValue("--ha-animation-duration-fast")
|
||||
) || 150
|
||||
);
|
||||
}
|
||||
|
||||
private _handleSlotchange(ev: Event) {
|
||||
this._observer.disconnect();
|
||||
const slot = ev.target as HTMLSlotElement;
|
||||
for (const el of slot.assignedElements()) {
|
||||
this._observer.observe(el, {
|
||||
attributes: true,
|
||||
attributeFilter: ["selected", "disabled"],
|
||||
});
|
||||
}
|
||||
// Positions are only valid once the slotted buttons are laid out.
|
||||
requestAnimationFrame(() => this._updateThumb());
|
||||
}
|
||||
|
||||
private _updateThumb() {
|
||||
const selected = this.querySelector<HTMLElement>(
|
||||
"ha-icon-button-toggle[selected]:not([disabled])"
|
||||
);
|
||||
if (!selected) {
|
||||
this._thumbVisible = false;
|
||||
return;
|
||||
}
|
||||
this._thumbAppearing = !this._thumbVisible;
|
||||
this._thumbBorderOnly = selected.hasAttribute("border-only");
|
||||
this._thumbX =
|
||||
selected.offsetLeft + (selected.offsetWidth - THUMB_SIZE) / 2;
|
||||
this._thumbVisible = true;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
@@ -21,6 +99,32 @@ export class HaIconButtonGroup extends LitElement {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
/* The selected toggle's circle is drawn here so it can slide between
|
||||
toggles; their own circles are suppressed below. */
|
||||
.thumb {
|
||||
position: absolute;
|
||||
top: calc(50% - 20px);
|
||||
opacity: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
background-color: var(
|
||||
--ha-icon-button-group-thumb-color,
|
||||
var(--primary-text-color)
|
||||
);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.thumb.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.thumb.border-only {
|
||||
background-color: transparent;
|
||||
border: 2px solid
|
||||
var(--ha-icon-button-group-thumb-color, var(--primary-text-color));
|
||||
}
|
||||
::slotted(ha-icon-button-toggle) {
|
||||
--ha-icon-button-toggle-thumb-opacity: 0;
|
||||
}
|
||||
::slotted(.separator) {
|
||||
background-color: rgba(var(--rgb-primary-text-color), 0.15);
|
||||
width: 1px;
|
||||
|
||||
@@ -44,8 +44,10 @@ export class HaIconButtonToggle extends HaIconButton {
|
||||
color: var(--primary-background-color);
|
||||
background-color: unset;
|
||||
}
|
||||
/* ha-icon-button-group zeroes this so its sliding thumb draws the
|
||||
circle instead. */
|
||||
:host([selected]:not([disabled])) ha-button::part(base)::before {
|
||||
opacity: 1;
|
||||
opacity: var(--ha-icon-button-toggle-thumb-opacity, 1);
|
||||
}
|
||||
::slotted(*) {
|
||||
display: block;
|
||||
|
||||
@@ -472,10 +472,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
const newValue = value?.trim();
|
||||
const newTab = ev.ctrlKey || ev.metaKey;
|
||||
|
||||
this._fireSelectedEvents(newValue, index, newTab);
|
||||
this._fireSelectedEvents(value, index, newTab);
|
||||
};
|
||||
|
||||
private _fireSelectedEvents(value: string, index: number, newTab = false) {
|
||||
|
||||
@@ -56,6 +56,7 @@ export class HaSelectBox extends LitElement {
|
||||
class="list"
|
||||
style=${styleMap({ "--columns": columns })}
|
||||
.value=${this.value}
|
||||
?disabled=${this.disabled}
|
||||
@change=${this._radioChanged}
|
||||
>
|
||||
${this.options.map((option) => this._renderOption(option))}
|
||||
@@ -100,7 +101,7 @@ export class HaSelectBox extends LitElement {
|
||||
)}
|
||||
aria-labelledby=${`label-${option.value}`}
|
||||
.value=${option.value}
|
||||
.disabled=${disabled}
|
||||
.disabled=${option.disabled || false}
|
||||
></ha-radio-option>
|
||||
<div class="text">
|
||||
<span id=${`label-${option.value}`} class="label"
|
||||
|
||||
@@ -53,7 +53,6 @@ export class HaSelectorAutomationBehavior extends LitElement {
|
||||
value: behavior,
|
||||
label: this._localizeOption(behavior, "label"),
|
||||
description: this._localizeOption(behavior, "description"),
|
||||
disabled: this.disabled,
|
||||
...(isTrigger && {
|
||||
image: {
|
||||
src: `/static/images/form/automation_behavior_trigger_${behavior}.svg`,
|
||||
@@ -66,6 +65,7 @@ export class HaSelectorAutomationBehavior extends LitElement {
|
||||
<ha-select-box
|
||||
.options=${options}
|
||||
.value=${this.value ?? ""}
|
||||
.disabled=${this.disabled}
|
||||
max_columns="1"
|
||||
?stacked_image=${isTrigger}
|
||||
@value-changed=${this._valueChanged}
|
||||
|
||||
@@ -81,6 +81,7 @@ export class HaChooseSelector extends LitElement {
|
||||
.required=${this.required}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
.helper=${this.helper}
|
||||
.localizeValue=${this.localizeValue}
|
||||
></ha-selector>`;
|
||||
}
|
||||
|
||||
@@ -107,7 +108,12 @@ export class HaChooseSelector extends LitElement {
|
||||
: {
|
||||
[this._activeChoice!]: this.value,
|
||||
};
|
||||
this._activeChoice = ev.detail?.value || ev.target.value;
|
||||
const choice = ev.detail?.value || ev.target.value;
|
||||
this._activeChoice = choice;
|
||||
if (choice && "constant" in this.selector.choose.choices[choice].selector) {
|
||||
value[choice] =
|
||||
this.selector.choose.choices[choice].selector.constant?.value;
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...value,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { ConfigEntry } from "../../data/config_entries";
|
||||
import { getConfigEntries } from "../../data/config_entries";
|
||||
import { getDeviceIntegrationLookup } from "../../data/device/device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import type { EntitySources } from "../../data/entity/entity_sources";
|
||||
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
|
||||
import type { EntitySelector } from "../../data/selector";
|
||||
@@ -42,10 +41,6 @@ export class HaEntitySelector extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@property({ attribute: false }) public context?: {
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
};
|
||||
|
||||
@state() private _createDomains: string[] | undefined;
|
||||
|
||||
private _deviceIntegrationLookup = memoizeOne(
|
||||
@@ -174,9 +169,6 @@ export class HaEntitySelector extends LitElement {
|
||||
}
|
||||
|
||||
private _filterEntities = (entity: HassEntity): boolean => {
|
||||
if (this.context?.entityFilter && !this.context.entityFilter(entity)) {
|
||||
return false;
|
||||
}
|
||||
if (!this.selector?.entity?.filter) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ export class HaNumberSelector extends LitElement {
|
||||
.hint=${isBox ? this.helper : undefined}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.validationMessage=${this.selector.number?.validation_message}
|
||||
type="number"
|
||||
autoValidate
|
||||
.withoutSpinButtons=${!isBox}
|
||||
@@ -176,7 +177,7 @@ export class HaNumberSelector extends LitElement {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
direction: ltr;
|
||||
direction: var(--direction);
|
||||
}
|
||||
ha-slider {
|
||||
flex: 1;
|
||||
|
||||
@@ -104,6 +104,7 @@ export class HaSelectSelector extends LitElement {
|
||||
<ha-select-box
|
||||
.options=${options}
|
||||
.value=${this.value as string | undefined}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._selectChanged}
|
||||
.maxColumns=${this.selector.select?.box_max_columns}
|
||||
></ha-select-box>
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "../data/selector";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import { documentationUrl } from "../util/documentation-url";
|
||||
import { getSelectorFallbackValue } from "./ha-form/get-selector-fallback-value";
|
||||
import "./ha-checkbox";
|
||||
import type { HaCheckbox } from "./ha-checkbox";
|
||||
import "./ha-icon-button";
|
||||
@@ -799,20 +800,8 @@ export class HaServiceControl extends LitElement {
|
||||
|
||||
let defaultValue = field?.default;
|
||||
|
||||
if (
|
||||
defaultValue == null &&
|
||||
field?.selector &&
|
||||
"constant" in field.selector
|
||||
) {
|
||||
defaultValue = field.selector.constant?.value;
|
||||
}
|
||||
|
||||
if (
|
||||
defaultValue == null &&
|
||||
field?.selector &&
|
||||
"boolean" in field.selector
|
||||
) {
|
||||
defaultValue = false;
|
||||
if (defaultValue == null && field?.selector) {
|
||||
defaultValue = getSelectorFallbackValue(field.selector);
|
||||
}
|
||||
|
||||
if (defaultValue != null) {
|
||||
|
||||
@@ -664,6 +664,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type DevicePickerItem,
|
||||
} from "../data/device/device_picker";
|
||||
import {
|
||||
devicesInEffectiveArea,
|
||||
fetchDeviceCompositeSplits,
|
||||
type DeviceCompositeSplits,
|
||||
} from "../data/device/device_registry";
|
||||
@@ -158,6 +159,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
excludeDevices,
|
||||
value,
|
||||
idPrefix,
|
||||
nested: true,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -682,9 +684,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
});
|
||||
} else if (type === "area") {
|
||||
Object.values(this.hass.devices).forEach((device) => {
|
||||
// Splitting an area yields its effective-area devices, so a child device
|
||||
// that belongs to a different area is not pulled into this area.
|
||||
devicesInEffectiveArea(this.hass.devices, itemId).forEach((device) => {
|
||||
if (
|
||||
device.area_id === itemId &&
|
||||
!this.value!.device_id?.includes(device.id) &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
@@ -716,9 +719,18 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
});
|
||||
} else if (type === "device") {
|
||||
// Splitting a device into entities includes its child devices' entities,
|
||||
// since targeting the device would target its children too.
|
||||
const deviceIds = new Set([
|
||||
itemId,
|
||||
...Object.values(this.hass.devices)
|
||||
.filter((device) => device.parent_device_id === itemId)
|
||||
.map((device) => device.id),
|
||||
]);
|
||||
Object.values(this.hass.entities).forEach((entity) => {
|
||||
if (
|
||||
entity.device_id === itemId &&
|
||||
entity.device_id &&
|
||||
deviceIds.has(entity.device_id) &&
|
||||
!this.value!.entity_id?.includes(entity.entity_id) &&
|
||||
entityRegMeetsFilter(
|
||||
entity,
|
||||
@@ -1001,6 +1013,28 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
if (!filterType || filterType === "device") {
|
||||
const selectedDeviceIds = targetValue?.device_id
|
||||
? replacingDeviceId
|
||||
? ensureArray(targetValue.device_id).filter(
|
||||
(deviceId) => deviceId !== replacingDeviceId
|
||||
)
|
||||
: ensureArray(targetValue.device_id)
|
||||
: undefined;
|
||||
// A selected parent device already targets its children, so exclude
|
||||
// those children from the picker too (mirrors selecting a floor
|
||||
// removing its areas from the list).
|
||||
const excludeDeviceIds = selectedDeviceIds
|
||||
? [
|
||||
...selectedDeviceIds,
|
||||
...Object.values(this.hass.devices)
|
||||
.filter(
|
||||
(device) =>
|
||||
device.parent_device_id !== null &&
|
||||
selectedDeviceIds.includes(device.parent_device_id)
|
||||
)
|
||||
.map((device) => device.id),
|
||||
]
|
||||
: undefined;
|
||||
let deviceItems = this._getDevicesMemoized(
|
||||
this.hass,
|
||||
configEntryLookup,
|
||||
@@ -1008,26 +1042,41 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
includeDeviceClasses,
|
||||
deviceFilter,
|
||||
entityFilter,
|
||||
targetValue?.device_id
|
||||
? replacingDeviceId
|
||||
? ensureArray(targetValue.device_id).filter(
|
||||
(deviceId) => deviceId !== replacingDeviceId
|
||||
)
|
||||
: ensureArray(targetValue.device_id)
|
||||
: undefined,
|
||||
excludeDeviceIds,
|
||||
replacingDeviceId,
|
||||
`device${SEPARATOR}`
|
||||
).sort(this._sortBySortingLabel);
|
||||
);
|
||||
// getDevices already returns child devices nested under their parent
|
||||
// with the top-level devices sorted; keep that order rather than
|
||||
// re-sorting by label, which would separate children from their parent.
|
||||
|
||||
if (searchTerm) {
|
||||
// Keep the nested parent-then-children order (sort=false), matching
|
||||
// the areas group; the default sorted search would reorder matches by
|
||||
// relevance and pull children above their parent.
|
||||
deviceItems = this._filterGroup(
|
||||
"device",
|
||||
deviceItems,
|
||||
searchTerm,
|
||||
deviceComboBoxKeys
|
||||
deviceComboBoxKeys,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// Recompute the tree "last child" flag over the (possibly filtered)
|
||||
// list so the last visible child of each parent draws its end connector.
|
||||
deviceItems = deviceItems.map((item, index) => {
|
||||
if (!(item as DevicePickerItem).is_child) {
|
||||
return item;
|
||||
}
|
||||
const nextItem = deviceItems[index + 1] as
|
||||
DevicePickerItem | undefined;
|
||||
return {
|
||||
...item,
|
||||
last: !nextItem || !nextItem.is_child,
|
||||
};
|
||||
});
|
||||
|
||||
if (!filterType && deviceItems.length) {
|
||||
// show group title
|
||||
items.push(localize("ui.components.target-picker.type.devices"));
|
||||
@@ -1245,7 +1294,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
let hasFloor = false;
|
||||
let rtl = false;
|
||||
let showEntityId = false;
|
||||
if (type === "area" || type === "floor") {
|
||||
const isChildDeviceRow =
|
||||
type === "device" && !!(item as DevicePickerItem).is_child;
|
||||
if (type === "area" || type === "floor" || isChildDeviceRow) {
|
||||
rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
@@ -1265,27 +1316,27 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
.type=${type === "empty" ? "text" : "button"}
|
||||
class=${type === "empty" ? "empty" : ""}
|
||||
style=${
|
||||
(item as FloorComboBoxItem).type === "area" && hasFloor
|
||||
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
|
||||
isChildDeviceRow
|
||||
? "--md-list-item-leading-space: var(--ha-space-12);"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
${
|
||||
(item as FloorComboBoxItem).type === "area" && hasFloor
|
||||
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
|
||||
isChildDeviceRow
|
||||
? html`
|
||||
<ha-tree-indicator
|
||||
style=${styleMap({
|
||||
width: "var(--ha-space-12)",
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
height: "100%",
|
||||
left: rtl ? undefined : "var(--ha-space-1)",
|
||||
right: rtl ? "var(--ha-space-1)" : undefined,
|
||||
transform: rtl ? "scaleX(-1)" : "",
|
||||
})}
|
||||
.end=${
|
||||
(item as FloorComboBoxItem & { last?: boolean | undefined })
|
||||
.last
|
||||
}
|
||||
.end=${(item as { last?: boolean }).last}
|
||||
slot="start"
|
||||
></ha-tree-indicator>
|
||||
`
|
||||
|
||||
@@ -8,10 +8,31 @@ export class HaTreeIndicator extends LitElement {
|
||||
public end?: boolean = false;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
// preserveAspectRatio="none" lets the connector stretch to the host box, so
|
||||
// it can span the full height of a taller row instead of being letterboxed
|
||||
// to a square in the middle. non-scaling-stroke keeps the line width and
|
||||
// dash pattern identical no matter how far it is stretched.
|
||||
return html`
|
||||
<svg width="100%" height="100%" viewBox="0 0 48 48">
|
||||
<line x1="24" y1="0" x2="24" y2=${this.end ? "24" : "48"}></line>
|
||||
<line x1="24" y1="24" x2="36" y2="24"></line>
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 48 48"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<line
|
||||
x1="24"
|
||||
y1="0"
|
||||
x2="24"
|
||||
y2=${this.end ? "24" : "48"}
|
||||
vector-effect="non-scaling-stroke"
|
||||
></line>
|
||||
<line
|
||||
x1="24"
|
||||
y1="24"
|
||||
x2="36"
|
||||
y2="24"
|
||||
vector-effect="non-scaling-stroke"
|
||||
></line>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
import { apiContext, connectionContext } from "../data/context";
|
||||
import "./ha-alert";
|
||||
|
||||
const HIDDEN_CLEANUP_DELAY = 60000;
|
||||
|
||||
/**
|
||||
* A WebRTC stream is established by first sending an offer through a signal
|
||||
* path via an integration. An answer is returned, then the rest of the stream
|
||||
@@ -68,13 +70,22 @@ class HaWebRtcPlayer extends LitElement {
|
||||
|
||||
private _candidatesList: RTCIceCandidate[] = [];
|
||||
|
||||
private _hiddenCleanupTimeout?: number;
|
||||
|
||||
private _handleVisibilityChange = () => {
|
||||
if (document.pictureInPictureElement) {
|
||||
// video is playing in picture-in-picture mode, don't do anything
|
||||
return;
|
||||
}
|
||||
if (document.hidden) {
|
||||
this._cleanUp();
|
||||
this._hiddenCleanupTimeout = window.setTimeout(() => {
|
||||
this._hiddenCleanupTimeout = undefined;
|
||||
this._cleanUp();
|
||||
}, HIDDEN_CLEANUP_DELAY);
|
||||
} else if (this._hiddenCleanupTimeout) {
|
||||
// stream was not cleaned up yet, just cancel the cleanup
|
||||
clearTimeout(this._hiddenCleanupTimeout);
|
||||
this._hiddenCleanupTimeout = undefined;
|
||||
} else {
|
||||
this._startWebRtc();
|
||||
}
|
||||
@@ -116,6 +127,8 @@ class HaWebRtcPlayer extends LitElement {
|
||||
"visibilitychange",
|
||||
this._handleVisibilityChange
|
||||
);
|
||||
clearTimeout(this._hiddenCleanupTimeout);
|
||||
this._hiddenCleanupTimeout = undefined;
|
||||
this._cleanUp();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { HaListItemBase } from "./ha-list-item-base";
|
||||
|
||||
/**
|
||||
* @element ha-list-item-value
|
||||
* @extends {HaListItemBase}
|
||||
*
|
||||
* @summary
|
||||
* Non-interactive label/value row for grouped lists: label on the start
|
||||
* side, value content end-aligned. The value is the default slot so callers
|
||||
* can render rich content (links, secondary lines).
|
||||
*
|
||||
* @slot - The value content.
|
||||
*
|
||||
* @csspart label - The label column.
|
||||
* @csspart value - The value column.
|
||||
*
|
||||
* @cssprop --ha-list-item-value-max-width - Maximum width of the value column. Defaults to 60%.
|
||||
*
|
||||
* @attr {string} label - The row label.
|
||||
*/
|
||||
@customElement("ha-list-item-value")
|
||||
export class HaListItemValue extends HaListItemBase {
|
||||
@property({ type: String }) public label?: string;
|
||||
|
||||
protected override _renderInner(): TemplateResult {
|
||||
return html`
|
||||
<div part="label" class="label">${this.label}</div>
|
||||
<div part="value" class="value"><slot></slot></div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = [
|
||||
HaListItemBase.styles,
|
||||
css`
|
||||
:host {
|
||||
--ha-row-item-padding-block: var(--ha-space-2);
|
||||
--ha-row-item-min-height: 40px;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.value {
|
||||
max-width: var(--ha-list-item-value-max-width, 60%);
|
||||
min-width: 0;
|
||||
text-align: end;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-list-item-value": HaListItemValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { HaListBase } from "./ha-list-base";
|
||||
|
||||
/**
|
||||
* @element ha-grouped-list
|
||||
* @extends {HaListBase}
|
||||
*
|
||||
* @summary
|
||||
* Grouped list: an optional header above a framed box of rows separated by
|
||||
* hairlines — the "grouped list" idiom of settings and detail views. Items
|
||||
* are `<ha-list-item-*>` rows; use `ha-list-item-value` for label/value
|
||||
* facts and `ha-list-item-button` for navigable rows.
|
||||
*
|
||||
* @slot - List items (`<ha-list-item-*>`).
|
||||
*
|
||||
* @csspart header - The header above the frame.
|
||||
* @csspart base - The framed `<div role="list">`.
|
||||
*
|
||||
* @cssprop --ha-row-item-padding-inline - Horizontal padding of the rows, which the header aligns to. Defaults to `--ha-space-3`.
|
||||
*
|
||||
* @attr {string} header - Header text rendered above the frame.
|
||||
*/
|
||||
@customElement("ha-grouped-list")
|
||||
export class HaGroupedList extends HaListBase {
|
||||
// The frame carries the list role so the header stays out of the list
|
||||
// semantics.
|
||||
protected override readonly hostRole = "";
|
||||
|
||||
@property({ type: String }) public header?: string;
|
||||
|
||||
protected override render(): TemplateResult {
|
||||
return html`
|
||||
${
|
||||
this.header
|
||||
? html`<div part="header" class="header" id="header">
|
||||
${this.header}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
<div
|
||||
part="base"
|
||||
class="base"
|
||||
role="list"
|
||||
aria-labelledby=${ifDefined(this.header ? "header" : undefined)}
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
...HaListBase.styles,
|
||||
css`
|
||||
:host {
|
||||
--ha-row-item-padding-inline: var(--ha-space-3);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin: 0 0 var(--ha-space-1);
|
||||
margin-inline-start: calc(
|
||||
var(--ha-row-item-padding-inline) + var(--ha-border-width-sm)
|
||||
);
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.base {
|
||||
border: var(--ha-border-width-sm) solid var(--divider-color);
|
||||
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::slotted(:not(:first-child)) {
|
||||
border-top: var(--ha-border-width-sm) solid var(--divider-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-grouped-list": HaGroupedList;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
|
||||
import type {
|
||||
Circle,
|
||||
CircleMarker,
|
||||
Control,
|
||||
LatLngExpression,
|
||||
LatLngTuple,
|
||||
Layer,
|
||||
@@ -48,6 +49,7 @@ import type {
|
||||
import { isTouch } from "../../util/is_touch";
|
||||
import "../ha-icon-button";
|
||||
import "./ha-entity-marker";
|
||||
import { UNIT_KM } from "../../common/const";
|
||||
|
||||
declare global {
|
||||
// for fire event
|
||||
@@ -147,6 +149,9 @@ export class HaMap extends ReactiveElement {
|
||||
@property({ attribute: "cluster-markers", type: Boolean })
|
||||
public clusterMarkers = true;
|
||||
|
||||
@property({ attribute: "scale-ruler", type: Boolean })
|
||||
public scaleRuler = false;
|
||||
|
||||
@state() private _loaded = false;
|
||||
|
||||
@query("#map") private _mapElement?: HTMLElement;
|
||||
@@ -167,6 +172,8 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
private _mapCluster: MarkerClusterGroup | undefined;
|
||||
|
||||
private _scaleRulerControl?: Control.Scale;
|
||||
|
||||
private _mapPaths: (Polyline | CircleMarker)[] = [];
|
||||
|
||||
private _clickCount = 0;
|
||||
@@ -206,6 +213,8 @@ export class HaMap extends ReactiveElement {
|
||||
this.Leaflet = undefined;
|
||||
}
|
||||
|
||||
// the control went away with the map, so don't hold on to it
|
||||
this._scaleRulerControl = undefined;
|
||||
this._pendingFit = undefined;
|
||||
this._loaded = false;
|
||||
|
||||
@@ -243,6 +252,16 @@ export class HaMap extends ReactiveElement {
|
||||
this._drawEntities();
|
||||
}
|
||||
|
||||
const oldConfig = changedProps.get("_config") as HassConfig | undefined;
|
||||
if (
|
||||
changedProps.has("_loaded") ||
|
||||
changedProps.has("scaleRuler") ||
|
||||
(changedProps.has("_config") &&
|
||||
oldConfig?.unit_system?.length !== this._config?.unit_system?.length)
|
||||
) {
|
||||
this._drawScaleRuler();
|
||||
}
|
||||
|
||||
if (changedProps.has("_loaded") || changedProps.has("paths")) {
|
||||
this._drawPaths();
|
||||
}
|
||||
@@ -806,6 +825,25 @@ export class HaMap extends ReactiveElement {
|
||||
this._mapZones.forEach((marker) => map.addLayer(marker));
|
||||
}
|
||||
|
||||
private _drawScaleRuler(): void {
|
||||
if (this._scaleRulerControl) {
|
||||
this.leafletMap?.removeControl(this._scaleRulerControl);
|
||||
this._scaleRulerControl = undefined;
|
||||
}
|
||||
|
||||
if (!this.scaleRuler || !this.leafletMap || !this.Leaflet) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metric = this._config?.unit_system?.length === UNIT_KM;
|
||||
this._scaleRulerControl = this.Leaflet.control.scale({
|
||||
position: "bottomleft",
|
||||
metric,
|
||||
imperial: !metric,
|
||||
});
|
||||
this._scaleRulerControl.addTo(this.leafletMap);
|
||||
}
|
||||
|
||||
private _getMarkerSize(computedStyles: CSSStyleDeclaration): number {
|
||||
const markerSizeVarValue =
|
||||
computedStyles.getPropertyValue("--ha-marker-size");
|
||||
@@ -886,6 +924,37 @@ export class HaMap extends ReactiveElement {
|
||||
.leaflet-bottom {
|
||||
z-index: 1 !important;
|
||||
}
|
||||
.leaflet-control-scale {
|
||||
cursor: unset !important;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
--scale-ruler-color: var(--ha-color-on-surface-default);
|
||||
--scale-ruler-surface: var(--ha-color-surface-default);
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-family: var(--ha-font-family-body);
|
||||
color: var(--scale-ruler-color) !important;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--scale-ruler-surface) 80%,
|
||||
transparent
|
||||
) !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
/* the theme tokens follow the page, so forced modes need the opposite values */
|
||||
#map.forced-light .leaflet-control-scale-line {
|
||||
--scale-ruler-color: var(--ha-color-neutral-05);
|
||||
--scale-ruler-surface: var(--ha-color-white);
|
||||
}
|
||||
#map.forced-dark .leaflet-control-scale-line {
|
||||
--scale-ruler-color: var(--ha-color-neutral-95);
|
||||
--scale-ruler-surface: var(--ha-color-neutral-10);
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 10px !important;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
.leaflet-tooltip {
|
||||
padding: 8px;
|
||||
font-size: var(--ha-font-size-s);
|
||||
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
type TargetType,
|
||||
} from "../../data/target";
|
||||
import { showMoreInfoDialog } from "../../dialogs/more-info/show-ha-more-info-dialog";
|
||||
import { buttonLinkStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
|
||||
@@ -221,30 +220,28 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
? html`
|
||||
<div slot="end" class="summary">
|
||||
${
|
||||
showEntities &&
|
||||
!this.expand &&
|
||||
entries?.referenced_entities.length
|
||||
? html`<button
|
||||
class="main link"
|
||||
this.expand || !entries.referenced_entities.length
|
||||
? html`<span class="main">
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.entities_count",
|
||||
{
|
||||
count: entries.referenced_entities.length,
|
||||
}
|
||||
)}
|
||||
</span>`
|
||||
: html`<ha-button
|
||||
appearance="filled"
|
||||
variant="brand"
|
||||
size="xs"
|
||||
@click=${this._openDetails}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.entities_count",
|
||||
{
|
||||
count: entries?.referenced_entities.length,
|
||||
count: entries.referenced_entities.length,
|
||||
}
|
||||
)}
|
||||
</button>`
|
||||
: showEntities
|
||||
? html`<span class="main">
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.entities_count",
|
||||
{
|
||||
count: entries?.referenced_entities.length,
|
||||
}
|
||||
)}
|
||||
</span>`
|
||||
: nothing
|
||||
</ha-button>`
|
||||
}
|
||||
</div>
|
||||
`
|
||||
@@ -396,6 +393,9 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
nextEntries.referenced_entities =
|
||||
entries?.referenced_entities.filter((entity_id) => {
|
||||
const entity = this.hass.entities[entity_id];
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
entity.area_id === rowItem ||
|
||||
!entity.device_id ||
|
||||
@@ -419,6 +419,9 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
this.type === "label" && entries
|
||||
? entries.referenced_entities.filter((entity_id) => {
|
||||
const entity = this.hass.entities[entity_id];
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
entity.labels.includes(this.itemId) &&
|
||||
!entries.referenced_devices.includes(entity.device_id || "")
|
||||
@@ -427,7 +430,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
: nextType === "device" && entries
|
||||
? entries.referenced_entities.filter(
|
||||
(entity_id) =>
|
||||
this.hass.entities[entity_id].area_id === this.itemId
|
||||
this.hass.entities[entity_id]?.area_id === this.itemId
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -436,7 +439,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
? entries.referenced_devices.filter(
|
||||
(device_id) =>
|
||||
!devicesInAreas.includes(device_id) &&
|
||||
this.hass.devices[device_id].labels.includes(this.itemId)
|
||||
this.hass.devices[device_id]?.labels.includes(this.itemId)
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -531,6 +534,12 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
entries.referenced_areas = entries.referenced_areas.filter(
|
||||
(area_id) => {
|
||||
const area = this.hass.areas[area_id];
|
||||
// Absent from the registry is not a filter decision: drop the id
|
||||
// without marking it hidden, so entities targeted through their
|
||||
// own area or label are not dropped along with it.
|
||||
if (!area) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(this.type === "floor" || area.labels.includes(this.itemId)) &&
|
||||
areaMeetsFilter(
|
||||
@@ -563,6 +572,9 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
entries.referenced_devices = entries.referenced_devices.filter(
|
||||
(device_id) => {
|
||||
const device = this.hass.devices[device_id];
|
||||
if (!device) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!hiddenAreaIds.includes(device.area_id || "") &&
|
||||
deviceMeetsFilter(
|
||||
@@ -588,6 +600,11 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
entries.referenced_entities = entries.referenced_entities.filter(
|
||||
(entity_id) => {
|
||||
const entity = this.hass.entities[entity_id];
|
||||
// Core can reference entities that are absent from the display
|
||||
// registry (e.g. disabled ones expanded from an area).
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
if (hiddenDeviceIds.includes(entity.device_id || "")) {
|
||||
return false;
|
||||
}
|
||||
@@ -812,7 +829,6 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
};
|
||||
|
||||
static styles = [
|
||||
buttonLinkStyle,
|
||||
css`
|
||||
:host {
|
||||
--md-list-item-top-space: 0;
|
||||
@@ -883,16 +899,6 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
button.link {
|
||||
text-decoration: none;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
button.link:hover,
|
||||
button.link:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.state {
|
||||
width: fit-content;
|
||||
font-size: var(--ha-font-size-s);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { stopPropagation } from "../../common/dom/stop_propagation";
|
||||
@@ -25,6 +25,13 @@ export class HaTileContainer extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public actionHandlerOptions?: ActionHandlerOptions;
|
||||
|
||||
@state() private _hasFeatures = false;
|
||||
|
||||
private _handleFeaturesSlotChange(ev: Event) {
|
||||
this._hasFeatures =
|
||||
(ev.target as HTMLSlotElement).assignedElements().length > 0;
|
||||
}
|
||||
|
||||
private _handleFocus(ev: FocusEvent) {
|
||||
if ((ev.target as HTMLElement).matches(":focus-visible")) {
|
||||
this.setAttribute("focused", "");
|
||||
@@ -36,8 +43,12 @@ export class HaTileContainer extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const containerOrientationClass =
|
||||
this.featurePosition === "inline" ? "horizontal" : "";
|
||||
const isInline = this.featurePosition === "inline";
|
||||
const containerClasses = {
|
||||
inline: isInline,
|
||||
"has-features-below": isInline && this._hasFeatures,
|
||||
"fixed-height": this.fixedInfoHeight,
|
||||
};
|
||||
const contentClasses = {
|
||||
vertical: this.vertical,
|
||||
"fixed-info-height": this.fixedInfoHeight,
|
||||
@@ -56,15 +67,21 @@ export class HaTileContainer extends LitElement {
|
||||
<ha-ripple .disabled=${!this.interactive}></ha-ripple>
|
||||
</div>
|
||||
<div
|
||||
class="container ${containerOrientationClass}"
|
||||
class="container ${classMap(containerClasses)}"
|
||||
@action=${stopPropagation}
|
||||
@click=${stopPropagation}
|
||||
>
|
||||
<div class="content ${classMap(contentClasses)}">
|
||||
<slot name="icon"></slot>
|
||||
<slot name="info" id="info"></slot>
|
||||
<div class="row">
|
||||
<div class="content ${classMap(contentClasses)}">
|
||||
<slot name="icon"></slot>
|
||||
<slot name="info" id="info"></slot>
|
||||
</div>
|
||||
<slot name="features-inline"></slot>
|
||||
</div>
|
||||
<slot name="features"></slot>
|
||||
<slot
|
||||
name="features"
|
||||
@slotchange=${this._handleFeaturesSlotChange}
|
||||
></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -97,7 +114,13 @@ export class HaTileContainer extends LitElement {
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
.container.horizontal {
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.container.inline .row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
@@ -153,13 +176,30 @@ export class HaTileContainer extends LitElement {
|
||||
padding: 0 var(--ha-space-3) var(--ha-space-3) var(--ha-space-3);
|
||||
}
|
||||
|
||||
.container.horizontal ::slotted([slot="features"]) {
|
||||
.container.inline ::slotted([slot="features-inline"]) {
|
||||
/* size the feature on the 6 column grid track, so it lines up with neighbouring tiles */
|
||||
width: calc(50% - var(--column-gap, 0px) / 2 - var(--ha-space-3));
|
||||
flex: none;
|
||||
--feature-height: var(--ha-space-9);
|
||||
padding: 0 var(--ha-space-3);
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
/* the inline feature keeps the icon height, unless the card reserves a row it can fill */
|
||||
.container.inline:not(.has-features-below)
|
||||
::slotted([slot="features-inline"]),
|
||||
.container.inline:not(.fixed-height) ::slotted([slot="features-inline"]) {
|
||||
--feature-height: var(--ha-space-9);
|
||||
}
|
||||
|
||||
.container.inline.has-features-below ::slotted([slot="features"]) {
|
||||
/* keep both columns under the inline feature, which sits on the grid track */
|
||||
--ha-card-feature-column-gap: calc(
|
||||
var(--column-gap, 0px) + var(--ha-space-3) * 2
|
||||
);
|
||||
--ha-card-feature-divider: 1px solid var(--ha-color-border-neutral-quiet);
|
||||
--ha-card-feature-divider-inset: calc(
|
||||
var(--ha-space-3) + var(--column-gap, 0px) / 2
|
||||
);
|
||||
}
|
||||
[role="button"] {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
|
||||
@@ -29,6 +29,7 @@ export class HaTraceLogbook extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.entries=${this.logbookEntries}
|
||||
.narrow=${this.narrow}
|
||||
no-detail
|
||||
></ha-logbook-renderer>
|
||||
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
|
||||
`
|
||||
|
||||
@@ -437,6 +437,7 @@ export class HaTracePathDetails extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.entries=${entries}
|
||||
.narrow=${this.narrow}
|
||||
no-detail
|
||||
></ha-logbook-renderer>
|
||||
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
|
||||
`
|
||||
|
||||
+210
-82
@@ -94,6 +94,45 @@ const localizeTimeString = (
|
||||
}
|
||||
};
|
||||
|
||||
// Seconds since midnight for a literal `HH:MM(:SS)` time, undefined for
|
||||
// anything else (entity ids contain a dot, and malformed input is ignored).
|
||||
const literalTimeToSeconds = (value: unknown): number | undefined => {
|
||||
if (typeof value !== "string" || value.includes(".")) {
|
||||
return undefined;
|
||||
}
|
||||
const chunks = value.split(":");
|
||||
if (chunks.length < 2 || chunks.length > 3) {
|
||||
return undefined;
|
||||
}
|
||||
const hours = Number(chunks[0]);
|
||||
const minutes = Number(chunks[1]);
|
||||
const seconds = chunks.length > 2 ? Number(chunks[2]) : 0;
|
||||
if (
|
||||
!Number.isFinite(hours) ||
|
||||
!Number.isFinite(minutes) ||
|
||||
!Number.isFinite(seconds)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
};
|
||||
|
||||
const numericThresholdSuffix = (config: {
|
||||
above?: number | string;
|
||||
below?: number | string;
|
||||
}): "above" | "below" | "above_below" | undefined => {
|
||||
if (config.above !== undefined && config.below !== undefined) {
|
||||
return "above_below";
|
||||
}
|
||||
if (config.above !== undefined) {
|
||||
return "above";
|
||||
}
|
||||
if (config.below !== undefined) {
|
||||
return "below";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const formatNumericLimitValue = (
|
||||
hass: HomeAssistant,
|
||||
value?: number | string
|
||||
@@ -107,18 +146,26 @@ const formatNumericLimitValue = (
|
||||
: value;
|
||||
};
|
||||
|
||||
export interface DescribeOptions {
|
||||
// Skip the user defined alias and describe the underlying config.
|
||||
ignoreAlias?: boolean;
|
||||
// Leave the entities out of the sentence, for rows that render them as
|
||||
// target badges.
|
||||
hideEntities?: boolean;
|
||||
}
|
||||
|
||||
export const describeTrigger = (
|
||||
trigger: Trigger,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
ignoreAlias = false
|
||||
options?: DescribeOptions
|
||||
): string => {
|
||||
try {
|
||||
const description = tryDescribeTrigger(
|
||||
trigger,
|
||||
hass,
|
||||
entityRegistry,
|
||||
ignoreAlias
|
||||
options
|
||||
);
|
||||
if (typeof description !== "string") {
|
||||
throw new Error(String(description));
|
||||
@@ -140,7 +187,7 @@ const tryDescribeTrigger = (
|
||||
trigger: Trigger,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
ignoreAlias = false
|
||||
options?: DescribeOptions
|
||||
) => {
|
||||
if (isTriggerList(trigger)) {
|
||||
const triggers = ensureArray(trigger.triggers);
|
||||
@@ -156,14 +203,15 @@ const tryDescribeTrigger = (
|
||||
});
|
||||
}
|
||||
|
||||
if (trigger.alias && !ignoreAlias) {
|
||||
if (trigger.alias && !options?.ignoreAlias) {
|
||||
return trigger.alias;
|
||||
}
|
||||
|
||||
const description = describeLegacyTrigger(
|
||||
trigger as LegacyTrigger,
|
||||
hass,
|
||||
entityRegistry
|
||||
entityRegistry,
|
||||
options?.hideEntities
|
||||
);
|
||||
|
||||
if (description) {
|
||||
@@ -187,7 +235,8 @@ const tryDescribeTrigger = (
|
||||
const describeLegacyTrigger = (
|
||||
trigger: LegacyTrigger,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[]
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
hideEntities = false
|
||||
) => {
|
||||
// Event Trigger
|
||||
if (trigger.trigger === "event" && trigger.event_type) {
|
||||
@@ -218,28 +267,16 @@ const describeLegacyTrigger = (
|
||||
}
|
||||
|
||||
// Numeric State Trigger
|
||||
if (trigger.trigger === "numeric_state" && trigger.entity_id) {
|
||||
const entities: string[] = [];
|
||||
if (
|
||||
trigger.trigger === "numeric_state" &&
|
||||
(trigger.entity_id || hideEntities)
|
||||
) {
|
||||
const states = hass.states;
|
||||
|
||||
const stateObj = Array.isArray(trigger.entity_id)
|
||||
? hass.states[trigger.entity_id[0]]
|
||||
: (hass.states[trigger.entity_id] as HassEntity | undefined);
|
||||
|
||||
if (Array.isArray(trigger.entity_id)) {
|
||||
for (const entity of trigger.entity_id.values()) {
|
||||
if (states[entity]) {
|
||||
entities.push(computeStateName(states[entity]) || entity);
|
||||
}
|
||||
}
|
||||
} else if (trigger.entity_id) {
|
||||
entities.push(
|
||||
states[trigger.entity_id]
|
||||
? computeStateName(states[trigger.entity_id])
|
||||
: trigger.entity_id
|
||||
);
|
||||
}
|
||||
|
||||
const attribute = trigger.attribute
|
||||
? stateObj
|
||||
? computeAttributeNameDisplay(
|
||||
@@ -255,6 +292,39 @@ const describeLegacyTrigger = (
|
||||
? describeDuration(hass.locale, trigger.for)
|
||||
: undefined;
|
||||
|
||||
if (hideEntities) {
|
||||
const suffix = numericThresholdSuffix(trigger);
|
||||
if (!suffix) {
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.label`
|
||||
);
|
||||
}
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.description.crossed_${suffix}`,
|
||||
{
|
||||
attribute: attribute,
|
||||
above: formatNumericLimitValue(hass, trigger.above),
|
||||
below: formatNumericLimitValue(hass, trigger.below),
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const entities: string[] = [];
|
||||
if (Array.isArray(trigger.entity_id)) {
|
||||
for (const entity of trigger.entity_id.values()) {
|
||||
if (states[entity]) {
|
||||
entities.push(computeStateName(states[entity]) || entity);
|
||||
}
|
||||
}
|
||||
} else if (trigger.entity_id) {
|
||||
entities.push(
|
||||
states[trigger.entity_id]
|
||||
? computeStateName(states[trigger.entity_id])
|
||||
: trigger.entity_id
|
||||
);
|
||||
}
|
||||
|
||||
if (trigger.above !== undefined && trigger.below !== undefined) {
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.description.above-below`,
|
||||
@@ -296,14 +366,14 @@ const describeLegacyTrigger = (
|
||||
|
||||
// State Trigger
|
||||
if (trigger.trigger === "state") {
|
||||
const entities: string[] = [];
|
||||
const states = hass.states;
|
||||
|
||||
const entityArray: string[] = ensureArray(trigger.entity_id);
|
||||
|
||||
const stateObj = hass.states[entityArray?.[0]] as HassEntity | undefined;
|
||||
|
||||
let attribute = "";
|
||||
if (trigger.attribute) {
|
||||
const stateObj = Array.isArray(trigger.entity_id)
|
||||
? hass.states[trigger.entity_id[0]]
|
||||
: (hass.states[trigger.entity_id] as HassEntity | undefined);
|
||||
attribute = stateObj
|
||||
? computeAttributeNameDisplay(
|
||||
hass.localize,
|
||||
@@ -314,17 +384,6 @@ const describeLegacyTrigger = (
|
||||
: trigger.attribute;
|
||||
}
|
||||
|
||||
const entityArray: string[] = ensureArray(trigger.entity_id);
|
||||
if (entityArray) {
|
||||
for (const entity of entityArray) {
|
||||
if (states[entity]) {
|
||||
entities.push(computeStateName(states[entity]) || entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stateObj = hass.states[entityArray[0]] as HassEntity | undefined;
|
||||
|
||||
let fromChoice = "other";
|
||||
let fromString = "";
|
||||
if (trigger.from !== undefined) {
|
||||
@@ -404,6 +463,32 @@ const describeLegacyTrigger = (
|
||||
duration = describeDuration(hass.locale, trigger.for) ?? "";
|
||||
}
|
||||
|
||||
if (hideEntities) {
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.state.description.changed`,
|
||||
{
|
||||
hasAttribute: attribute !== "" ? "true" : "false",
|
||||
attribute: attribute,
|
||||
anyChange: toChoice === "special" ? "true" : "false",
|
||||
fromChoice: fromChoice,
|
||||
fromString: fromString,
|
||||
toChoice: toChoice,
|
||||
toString: toString,
|
||||
hasDuration: duration !== "" ? "true" : "false",
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const entities: string[] = [];
|
||||
if (entityArray) {
|
||||
for (const entity of entityArray) {
|
||||
if (states[entity]) {
|
||||
entities.push(computeStateName(states[entity]) || entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.state.description.full`,
|
||||
{
|
||||
@@ -893,14 +978,14 @@ export const describeCondition = (
|
||||
condition: Condition,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
ignoreAlias = false
|
||||
options?: DescribeOptions
|
||||
): string => {
|
||||
try {
|
||||
const description = tryDescribeCondition(
|
||||
condition,
|
||||
hass,
|
||||
entityRegistry,
|
||||
ignoreAlias
|
||||
options
|
||||
);
|
||||
if (typeof description !== "string") {
|
||||
throw new Error(String(description));
|
||||
@@ -922,7 +1007,7 @@ const tryDescribeCondition = (
|
||||
condition: Condition,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
ignoreAlias = false
|
||||
options?: DescribeOptions
|
||||
) => {
|
||||
if (typeof condition === "string" && hasTemplate(condition)) {
|
||||
return hass.localize(
|
||||
@@ -930,7 +1015,7 @@ const tryDescribeCondition = (
|
||||
);
|
||||
}
|
||||
|
||||
if (condition.alias && !ignoreAlias) {
|
||||
if (condition.alias && !options?.ignoreAlias) {
|
||||
return condition.alias;
|
||||
}
|
||||
|
||||
@@ -952,7 +1037,8 @@ const tryDescribeCondition = (
|
||||
const description = describeLegacyCondition(
|
||||
condition as LegacyCondition,
|
||||
hass,
|
||||
entityRegistry
|
||||
entityRegistry,
|
||||
options?.hideEntities
|
||||
);
|
||||
|
||||
if (description) {
|
||||
@@ -978,7 +1064,8 @@ const tryDescribeCondition = (
|
||||
const describeLegacyCondition = (
|
||||
condition: LegacyCondition,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[]
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
hideEntities = false
|
||||
) => {
|
||||
if (condition.condition === "or") {
|
||||
const conditions = ensureArray(condition.conditions);
|
||||
@@ -1035,17 +1122,20 @@ const describeLegacyCondition = (
|
||||
|
||||
// State Condition
|
||||
if (condition.condition === "state") {
|
||||
if (!condition.entity_id) {
|
||||
if (!condition.entity_id && !hideEntities) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.state.description.no_entity`
|
||||
);
|
||||
}
|
||||
|
||||
const stateObj = hass.states[
|
||||
Array.isArray(condition.entity_id)
|
||||
? condition.entity_id[0]
|
||||
: condition.entity_id
|
||||
] as HassEntity | undefined;
|
||||
|
||||
let attribute = "";
|
||||
if (condition.attribute) {
|
||||
const stateObj = Array.isArray(condition.entity_id)
|
||||
? hass.states[condition.entity_id[0]]
|
||||
: (hass.states[condition.entity_id] as HassEntity | undefined);
|
||||
attribute = stateObj
|
||||
? computeAttributeNameDisplay(
|
||||
hass.localize,
|
||||
@@ -1056,27 +1146,7 @@ const describeLegacyCondition = (
|
||||
: condition.attribute;
|
||||
}
|
||||
|
||||
const entities: string[] = [];
|
||||
if (Array.isArray(condition.entity_id)) {
|
||||
for (const entity of condition.entity_id.values()) {
|
||||
if (hass.states[entity]) {
|
||||
entities.push(computeStateName(hass.states[entity]) || entity);
|
||||
}
|
||||
}
|
||||
} else if (condition.entity_id) {
|
||||
entities.push(
|
||||
hass.states[condition.entity_id]
|
||||
? computeStateName(hass.states[condition.entity_id])
|
||||
: condition.entity_id
|
||||
);
|
||||
}
|
||||
|
||||
const states: string[] = [];
|
||||
const stateObj = hass.states[
|
||||
Array.isArray(condition.entity_id)
|
||||
? condition.entity_id[0]
|
||||
: condition.entity_id
|
||||
] as HassEntity | undefined;
|
||||
if (Array.isArray(condition.state)) {
|
||||
for (const state of condition.state.values()) {
|
||||
states.push(
|
||||
@@ -1093,7 +1163,7 @@ const describeLegacyCondition = (
|
||||
: state
|
||||
);
|
||||
}
|
||||
} else if (condition.state !== "") {
|
||||
} else if (condition.state != null && condition.state !== "") {
|
||||
states.push(
|
||||
stateObj
|
||||
? condition.attribute
|
||||
@@ -1114,6 +1184,37 @@ const describeLegacyCondition = (
|
||||
duration = describeDuration(hass.locale, condition.for) || "";
|
||||
}
|
||||
|
||||
if (hideEntities) {
|
||||
if (states.length === 0) {
|
||||
return hass.localize(`${conditionsTranslationBaseKey}.state.label`);
|
||||
}
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.state.description.is`,
|
||||
{
|
||||
hasAttribute: attribute !== "" ? "true" : "false",
|
||||
attribute: attribute,
|
||||
states: formatListWithOrs(hass.locale, states),
|
||||
hasDuration: duration !== "" ? "true" : "false",
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const entities: string[] = [];
|
||||
if (Array.isArray(condition.entity_id)) {
|
||||
for (const entity of condition.entity_id.values()) {
|
||||
if (hass.states[entity]) {
|
||||
entities.push(computeStateName(hass.states[entity]) || entity);
|
||||
}
|
||||
}
|
||||
} else if (condition.entity_id) {
|
||||
entities.push(
|
||||
hass.states[condition.entity_id]
|
||||
? computeStateName(hass.states[condition.entity_id])
|
||||
: condition.entity_id
|
||||
);
|
||||
}
|
||||
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.state.description.full`,
|
||||
{
|
||||
@@ -1136,15 +1237,14 @@ const describeLegacyCondition = (
|
||||
}
|
||||
|
||||
// Numeric State Condition
|
||||
if (condition.condition === "numeric_state" && condition.entity_id) {
|
||||
const entity_ids = ensureArray(condition.entity_id);
|
||||
if (
|
||||
condition.condition === "numeric_state" &&
|
||||
(condition.entity_id || hideEntities)
|
||||
) {
|
||||
const entity_ids = condition.entity_id
|
||||
? ensureArray(condition.entity_id)
|
||||
: [];
|
||||
const stateObj = hass.states[entity_ids[0]] as HassEntity | undefined;
|
||||
const entity = formatListWithAnds(
|
||||
hass.locale,
|
||||
entity_ids.map((id) =>
|
||||
hass.states[id] ? computeStateName(hass.states[id]) : id || ""
|
||||
)
|
||||
);
|
||||
|
||||
const attribute = condition.attribute
|
||||
? stateObj
|
||||
@@ -1157,6 +1257,30 @@ const describeLegacyCondition = (
|
||||
: condition.attribute
|
||||
: undefined;
|
||||
|
||||
if (hideEntities) {
|
||||
const suffix = numericThresholdSuffix(condition);
|
||||
if (!suffix) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.label`
|
||||
);
|
||||
}
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.description.is_${suffix}`,
|
||||
{
|
||||
attribute,
|
||||
above: formatNumericLimitValue(hass, condition.above),
|
||||
below: formatNumericLimitValue(hass, condition.below),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const entity = formatListWithAnds(
|
||||
hass.locale,
|
||||
entity_ids.map((id) =>
|
||||
hass.states[id] ? computeStateName(hass.states[id]) : id || ""
|
||||
)
|
||||
);
|
||||
|
||||
if (condition.above !== undefined && condition.below !== undefined) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.description.above-below`,
|
||||
@@ -1232,12 +1356,16 @@ const describeLegacyCondition = (
|
||||
|
||||
let hasTime = "";
|
||||
if (after !== undefined && before !== undefined) {
|
||||
if (
|
||||
typeof condition.after === "string" &&
|
||||
!condition.after.includes(".") &&
|
||||
typeof condition.before === "string" &&
|
||||
!condition.before.includes(".") &&
|
||||
condition.after > condition.before
|
||||
const afterSeconds = literalTimeToSeconds(condition.after);
|
||||
const beforeSeconds = literalTimeToSeconds(condition.before);
|
||||
if (beforeSeconds === 0) {
|
||||
// A window ending at midnight runs to the end of the day, so the
|
||||
// "before" boundary adds nothing to the summary.
|
||||
hasTime = "after";
|
||||
} else if (
|
||||
afterSeconds !== undefined &&
|
||||
beforeSeconds !== undefined &&
|
||||
afterSeconds > beforeSeconds
|
||||
) {
|
||||
hasTime = "after_before_or";
|
||||
} else {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computeDeviceNameDisplay } from "../../common/entity/compute_device_nam
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { getDeviceArea } from "../../common/entity/context/get_device_context";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../../components/device/ha-device-picker";
|
||||
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
|
||||
@@ -24,12 +25,17 @@ import {
|
||||
export interface DevicePickerItem extends PickerComboBoxItem {
|
||||
domain?: string;
|
||||
domain_name?: string;
|
||||
// Set when this device is a child rendered indented under its parent.
|
||||
is_child?: boolean;
|
||||
// Set on the last child of a parent so the tree connector draws its end.
|
||||
last?: boolean;
|
||||
}
|
||||
|
||||
export interface DeviceAreaLabel {
|
||||
areaName?: string;
|
||||
viaDeviceName?: string;
|
||||
viaDeviceAreaName?: string;
|
||||
parentDeviceName?: string;
|
||||
}
|
||||
|
||||
export interface GetDevicesOptions {
|
||||
@@ -41,6 +47,10 @@ export interface GetDevicesOptions {
|
||||
excludeDevices?: string[];
|
||||
value?: string;
|
||||
idPrefix?: string;
|
||||
// When set, order the result so children directly follow their parent and
|
||||
// flag them for indented rendering. Requires the picker to disable its own
|
||||
// sorting (no-sort) so this order is preserved.
|
||||
nested?: boolean;
|
||||
}
|
||||
|
||||
export const computeDeviceAreaLabel = (
|
||||
@@ -53,7 +63,8 @@ export const computeDeviceAreaLabel = (
|
||||
translationMetadata: HomeAssistant["translationMetadata"],
|
||||
viaDeviceEntities?: EntityRegistryEntry[] | EntityRegistryDisplayEntry[]
|
||||
): DeviceAreaLabel => {
|
||||
const area = getDeviceArea(device, areas);
|
||||
// Pass devices so a child device inherits its parent's area.
|
||||
const area = getDeviceArea(device, areas, devices);
|
||||
|
||||
const viaDevice = device.via_device_id
|
||||
? devices[device.via_device_id]
|
||||
@@ -61,11 +72,23 @@ export const computeDeviceAreaLabel = (
|
||||
const viaDeviceName = viaDevice
|
||||
? computeDeviceNameDisplay(viaDevice, localize, states, viaDeviceEntities)
|
||||
: undefined;
|
||||
const viaDeviceArea = viaDevice ? getDeviceArea(viaDevice, areas) : undefined;
|
||||
const viaDeviceArea = viaDevice
|
||||
? getDeviceArea(viaDevice, areas, devices)
|
||||
: undefined;
|
||||
const viaDeviceAreaName = viaDeviceArea
|
||||
? computeAreaName(viaDeviceArea)
|
||||
: undefined;
|
||||
|
||||
// A child device is a logical part of its parent. We surface the parent name
|
||||
// only as a search term (below) — not in the area label, which stays the pure
|
||||
// (inherited) area. The nested tree rendering communicates the relationship.
|
||||
const parentDevice = device.parent_device_id
|
||||
? devices[device.parent_device_id]
|
||||
: undefined;
|
||||
const parentDeviceName = parentDevice
|
||||
? computeDeviceNameDisplay(parentDevice, localize, states)
|
||||
: undefined;
|
||||
|
||||
const isRTL = computeRTL(language, translationMetadata.translations);
|
||||
|
||||
const areaName = area
|
||||
@@ -74,7 +97,7 @@ export const computeDeviceAreaLabel = (
|
||||
? `${viaDeviceAreaName}${isRTL ? " ◂ " : " ▸ "}${viaDeviceName}`
|
||||
: viaDeviceName || undefined;
|
||||
|
||||
return { areaName, viaDeviceName, viaDeviceAreaName };
|
||||
return { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName };
|
||||
};
|
||||
|
||||
export const deviceComboBoxKeys: FuseWeightedKey[] = [
|
||||
@@ -102,6 +125,14 @@ export const deviceComboBoxKeys: FuseWeightedKey[] = [
|
||||
name: "search_labels.viaDeviceArea",
|
||||
weight: 3,
|
||||
},
|
||||
{
|
||||
name: "search_labels.parentDeviceName",
|
||||
weight: 3,
|
||||
},
|
||||
{
|
||||
name: "search_labels.childDeviceNames",
|
||||
weight: 3,
|
||||
},
|
||||
];
|
||||
|
||||
export const getDevices = (
|
||||
@@ -118,6 +149,7 @@ export const getDevices = (
|
||||
excludeDevices,
|
||||
value,
|
||||
idPrefix = "",
|
||||
nested,
|
||||
} = options ?? {};
|
||||
|
||||
const devices = Object.values(hass.devices);
|
||||
@@ -125,26 +157,60 @@ export const getDevices = (
|
||||
|
||||
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
|
||||
|
||||
if (
|
||||
includeDomains ||
|
||||
excludeDomains ||
|
||||
includeDeviceClasses ||
|
||||
entityFilter
|
||||
) {
|
||||
const filtersEntities =
|
||||
includeDomains || excludeDomains || includeDeviceClasses || entityFilter;
|
||||
|
||||
if (filtersEntities) {
|
||||
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
|
||||
}
|
||||
|
||||
// Targeting a device also targets its child devices (a parent inherits its
|
||||
// children's entities), so a device should match an entity-based filter when
|
||||
// it OR any of its children has a matching entity. Build a parent -> children
|
||||
// map and resolve each device's effective entity set accordingly. Nesting is
|
||||
// single-level, so one hop covers it.
|
||||
const filterChildrenByParent = new Map<string, DeviceRegistryEntry[]>();
|
||||
if (filtersEntities) {
|
||||
for (const device of devices) {
|
||||
if (device.parent_device_id) {
|
||||
const siblings = filterChildrenByParent.get(device.parent_device_id);
|
||||
if (siblings) {
|
||||
siblings.push(device);
|
||||
} else {
|
||||
filterChildrenByParent.set(device.parent_device_id, [device]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const effectiveEntities = (
|
||||
deviceId: string
|
||||
): EntityRegistryDisplayEntry[] => {
|
||||
const own = deviceEntityLookup[deviceId] ?? [];
|
||||
const children = filterChildrenByParent.get(deviceId);
|
||||
if (!children) {
|
||||
return own;
|
||||
}
|
||||
const combined = [...own];
|
||||
for (const child of children) {
|
||||
const childEntities = deviceEntityLookup[child.id];
|
||||
if (childEntities) {
|
||||
combined.push(...childEntities);
|
||||
}
|
||||
}
|
||||
return combined;
|
||||
};
|
||||
|
||||
let inputDevices = devices.filter(
|
||||
(device) => device.id === value || !device.disabled_by
|
||||
);
|
||||
|
||||
if (includeDomains) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
const devEntities = effectiveEntities(device.id);
|
||||
if (!devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) =>
|
||||
return devEntities.some((entity) =>
|
||||
includeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
});
|
||||
@@ -152,11 +218,11 @@ export const getDevices = (
|
||||
|
||||
if (excludeDomains) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
const devEntities = effectiveEntities(device.id);
|
||||
if (!devEntities.length) {
|
||||
return true;
|
||||
}
|
||||
return entities.every(
|
||||
return devEntities.every(
|
||||
(entity) => !excludeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
});
|
||||
@@ -170,11 +236,11 @@ export const getDevices = (
|
||||
|
||||
if (includeDeviceClasses) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
const devEntities = effectiveEntities(device.id);
|
||||
if (!devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) => {
|
||||
return devEntities.some((entity) => {
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
@@ -189,8 +255,8 @@ export const getDevices = (
|
||||
|
||||
if (entityFilter) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
const devEntities = effectiveEntities(device.id);
|
||||
if (!devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return devEntities.some((entity) => {
|
||||
@@ -219,7 +285,7 @@ export const getDevices = (
|
||||
deviceEntityLookup[device.id]
|
||||
);
|
||||
|
||||
const { areaName, viaDeviceName, viaDeviceAreaName } =
|
||||
const { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName } =
|
||||
computeDeviceAreaLabel(
|
||||
device,
|
||||
hass.areas,
|
||||
@@ -256,10 +322,79 @@ export const getDevices = (
|
||||
domainName: domainName || null,
|
||||
viaDeviceName: viaDeviceName || null,
|
||||
viaDeviceArea: viaDeviceAreaName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
},
|
||||
sorting_label: [primary, areaName, domainName].filter(Boolean).join("_"),
|
||||
};
|
||||
});
|
||||
|
||||
return outputDevices;
|
||||
if (!nested) {
|
||||
return outputDevices;
|
||||
}
|
||||
|
||||
// Order children directly after their parent and flag them for indented
|
||||
// rendering. outputDevices is 1:1 with inputDevices, so we can pair them up.
|
||||
const itemByDeviceId = new Map<string, DevicePickerItem>();
|
||||
inputDevices.forEach((device, index) => {
|
||||
itemByDeviceId.set(device.id, outputDevices[index]);
|
||||
});
|
||||
const presentIds = new Set(inputDevices.map((device) => device.id));
|
||||
|
||||
const childrenByParent = new Map<string, DeviceRegistryEntry[]>();
|
||||
const topLevel: DeviceRegistryEntry[] = [];
|
||||
for (const device of inputDevices) {
|
||||
const parentId = device.parent_device_id;
|
||||
// A child whose parent was filtered out is shown as a top-level row.
|
||||
if (parentId && presentIds.has(parentId)) {
|
||||
const siblings = childrenByParent.get(parentId);
|
||||
if (siblings) {
|
||||
siblings.push(device);
|
||||
} else {
|
||||
childrenByParent.set(parentId, [device]);
|
||||
}
|
||||
} else {
|
||||
topLevel.push(device);
|
||||
}
|
||||
}
|
||||
|
||||
const compareByName = (a: DeviceRegistryEntry, b: DeviceRegistryEntry) =>
|
||||
caseInsensitiveStringCompare(
|
||||
itemByDeviceId.get(a.id)!.primary,
|
||||
itemByDeviceId.get(b.id)!.primary,
|
||||
hass.locale.language
|
||||
);
|
||||
|
||||
topLevel.sort(compareByName);
|
||||
|
||||
const ordered: DevicePickerItem[] = [];
|
||||
for (const device of topLevel) {
|
||||
const parentItem = itemByDeviceId.get(device.id)!;
|
||||
const children = childrenByParent.get(device.id);
|
||||
if (children) {
|
||||
children.sort(compareByName);
|
||||
// Add the children's names to the parent's search terms so a search that
|
||||
// matches a child keeps the parent visible (mirrors how a floor stays
|
||||
// visible when one of its areas matches).
|
||||
ordered.push({
|
||||
...parentItem,
|
||||
search_labels: {
|
||||
...parentItem.search_labels,
|
||||
childDeviceNames: children
|
||||
.map((child) => itemByDeviceId.get(child.id)!.primary)
|
||||
.join(" "),
|
||||
},
|
||||
});
|
||||
children.forEach((child, index) => {
|
||||
ordered.push({
|
||||
...itemByDeviceId.get(child.id)!,
|
||||
is_child: true,
|
||||
last: index === children.length - 1,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
ordered.push(parentItem);
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
};
|
||||
|
||||
@@ -15,6 +15,13 @@ export {
|
||||
subscribeDeviceRegistry,
|
||||
} from "../ws-device_registry";
|
||||
|
||||
export type DeviceDisabler =
|
||||
| "user"
|
||||
| "integration"
|
||||
| "config_entry"
|
||||
// The device's parent device is disabled (child devices only).
|
||||
| "device";
|
||||
|
||||
export interface DeviceRegistryEntry extends RegistryEntry {
|
||||
id: string;
|
||||
config_entries: string[];
|
||||
@@ -33,11 +40,116 @@ export interface DeviceRegistryEntry extends RegistryEntry {
|
||||
area_id: string | null;
|
||||
name_by_user: string | null;
|
||||
entry_type: "service" | null;
|
||||
disabled_by: "user" | "integration" | "config_entry" | null;
|
||||
disabled_by: DeviceDisabler | null;
|
||||
configuration_url: string | null;
|
||||
primary_config_entry: string | null;
|
||||
// Set when this device is a child (logical part) of another device.
|
||||
// null for regular top-level devices.
|
||||
parent_device_id: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A child device as it arrives over the wire from
|
||||
* `config/device_registry/list`. A child is a lightweight logical part of a
|
||||
* parent device (e.g. an outlet of a power strip); it only carries its own
|
||||
* fields and inherits the rest from its parent. It is never stored in
|
||||
* `hass.devices` in this shape — {@link resolveChildDevices} turns every child
|
||||
* into a complete {@link DeviceRegistryEntry} at ingestion, so downstream code
|
||||
* only ever sees full device entries.
|
||||
*/
|
||||
export interface ChildDeviceRegistryEntry extends RegistryEntry {
|
||||
id: string;
|
||||
config_entry_id: string;
|
||||
config_subentry_id: string | null;
|
||||
identifiers: [string, string][];
|
||||
name: string | null;
|
||||
name_by_user: string | null;
|
||||
labels: string[];
|
||||
area_id: string | null;
|
||||
disabled_by: DeviceDisabler | null;
|
||||
parent_device_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw, mixed list returned by `config/device_registry/list`: full devices
|
||||
* and stripped children, discriminated by the presence of full-device fields.
|
||||
*/
|
||||
export type DeviceRegistryListEntry =
|
||||
DeviceRegistryEntry | ChildDeviceRegistryEntry;
|
||||
|
||||
/** Whether a resolved device entry is a child (logical part) of another device. */
|
||||
export const isChildDevice = (device: DeviceRegistryEntry): boolean =>
|
||||
device.parent_device_id !== null;
|
||||
|
||||
/**
|
||||
* Devices whose effective area is the given area: devices with that area, and
|
||||
* child devices that inherit it because they have no area of their own. Mirrors
|
||||
* core's dr.async_entries_for_area, so a child device with a different explicit
|
||||
* area is not part of its parent's area.
|
||||
*/
|
||||
export const devicesInEffectiveArea = (
|
||||
devices: Record<string, DeviceRegistryEntry>,
|
||||
areaId: string
|
||||
): DeviceRegistryEntry[] =>
|
||||
Object.values(devices).filter((device) => {
|
||||
if (device.area_id) {
|
||||
return device.area_id === areaId;
|
||||
}
|
||||
if (device.parent_device_id) {
|
||||
return devices[device.parent_device_id]?.area_id === areaId;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
export interface DeviceRowItem {
|
||||
device: DeviceRegistryEntry;
|
||||
isChild: boolean;
|
||||
// True for the last child of a parent, so the tree connector draws its end.
|
||||
isLastChild: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order a flat device list so each child directly follows its parent, flagging
|
||||
* children for indented rendering. The incoming order of the top-level devices
|
||||
* (and of the children within each parent) is preserved. A child whose parent
|
||||
* is not in the list is treated as a top-level device.
|
||||
*/
|
||||
export const groupDevicesByParent = (
|
||||
devices: DeviceRegistryEntry[]
|
||||
): DeviceRowItem[] => {
|
||||
const presentIds = new Set(devices.map((device) => device.id));
|
||||
const childrenByParent = new Map<string, DeviceRegistryEntry[]>();
|
||||
const topLevel: DeviceRegistryEntry[] = [];
|
||||
|
||||
for (const device of devices) {
|
||||
const parentId = device.parent_device_id;
|
||||
if (parentId && presentIds.has(parentId)) {
|
||||
const siblings = childrenByParent.get(parentId);
|
||||
if (siblings) {
|
||||
siblings.push(device);
|
||||
} else {
|
||||
childrenByParent.set(parentId, [device]);
|
||||
}
|
||||
} else {
|
||||
topLevel.push(device);
|
||||
}
|
||||
}
|
||||
|
||||
const result: DeviceRowItem[] = [];
|
||||
for (const device of topLevel) {
|
||||
result.push({ device, isChild: false, isLastChild: false });
|
||||
const children = childrenByParent.get(device.id) ?? [];
|
||||
children.forEach((child, index) => {
|
||||
result.push({
|
||||
device: child,
|
||||
isChild: true,
|
||||
isLastChild: index === children.length - 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export type DeviceEntityDisplayLookup = Record<
|
||||
string,
|
||||
EntityRegistryDisplayEntry[]
|
||||
@@ -146,15 +258,13 @@ export const updateDeviceRegistryEntry = (
|
||||
...updates,
|
||||
});
|
||||
|
||||
export const removeConfigEntryFromDevice = (
|
||||
export const removeDeviceFromRegistry = (
|
||||
hass: HomeAssistant,
|
||||
deviceId: string,
|
||||
configEntryId: string
|
||||
deviceId: string
|
||||
) =>
|
||||
hass.callWS<DeviceRegistryEntry>({
|
||||
type: "config/device_registry/remove_config_entry",
|
||||
hass.callWS<null>({
|
||||
type: "config/device_registry/remove",
|
||||
device_id: deviceId,
|
||||
config_entry_id: configEntryId,
|
||||
});
|
||||
|
||||
export const sortDeviceRegistryByName = (
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import type { DateRange } from "../common/datetime/calc_date_range";
|
||||
import { calcDateRange } from "../common/datetime/calc_date_range";
|
||||
import { formatTime24h } from "../common/datetime/format_time";
|
||||
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
|
||||
import { formatNumber } from "../common/number/format_number";
|
||||
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
|
||||
import { groupBy } from "../common/util/group-by";
|
||||
@@ -36,6 +37,7 @@ import type {
|
||||
import {
|
||||
fetchStatistics,
|
||||
getDisplayUnit,
|
||||
getStatisticLabel,
|
||||
getStatisticMetadata,
|
||||
VOLUME_UNITS,
|
||||
} from "./recorder";
|
||||
@@ -311,6 +313,59 @@ export interface EnergySourceByType {
|
||||
export const energySourcesByType = (prefs: EnergyPreferences) =>
|
||||
groupBy(prefs.energy_sources, (item) => item.type) as EnergySourceByType;
|
||||
|
||||
/**
|
||||
* Display name of a configured statistic. A name set by the user always wins;
|
||||
* otherwise the entity is named the same way the rest of the UI names
|
||||
* entities, so devices sharing an entity name stay distinguishable.
|
||||
* Statistics without an entity (external or removed) keep the statistic label.
|
||||
*/
|
||||
export const computeEnergyLabel = (
|
||||
hass: HomeAssistant,
|
||||
statisticId: string,
|
||||
statisticsMetaData?: StatisticsMetaData,
|
||||
customName?: string
|
||||
): string => {
|
||||
if (customName) {
|
||||
return customName;
|
||||
}
|
||||
|
||||
const stateObj = hass.states[statisticId];
|
||||
|
||||
if (stateObj) {
|
||||
return hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
|
||||
}
|
||||
|
||||
return getStatisticLabel(hass, statisticId, statisticsMetaData);
|
||||
};
|
||||
|
||||
/**
|
||||
* Device labels keyed by statistic id. Cards that show live power or flow
|
||||
* key their nodes by `stat_rate` instead of `stat_consumption`; devices
|
||||
* without the requested statistic are left out.
|
||||
*/
|
||||
export const computeEnergyDeviceLabels = (
|
||||
hass: HomeAssistant,
|
||||
devices: DeviceConsumptionEnergyPreference[],
|
||||
statsMetadata?: Record<string, StatisticsMetaData>,
|
||||
statisticKey: "stat_consumption" | "stat_rate" = "stat_consumption"
|
||||
): Record<string, string> => {
|
||||
const labels: Record<string, string> = {};
|
||||
|
||||
for (const device of devices) {
|
||||
const statisticId = device[statisticKey];
|
||||
if (statisticId) {
|
||||
labels[statisticId] = computeEnergyLabel(
|
||||
hass,
|
||||
statisticId,
|
||||
statsMetadata?.[statisticId],
|
||||
device.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return labels;
|
||||
};
|
||||
|
||||
export interface EnergyData {
|
||||
start: Date;
|
||||
end?: Date;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import type { Condition } from "../panels/lovelace/common/validate-condition";
|
||||
import type { ShortcutItem } from "./home_shortcuts";
|
||||
|
||||
export interface SurveyInteraction {
|
||||
@@ -36,18 +35,6 @@ export interface HomeFrontendSystemData {
|
||||
shortcuts?: ShortcutItem[];
|
||||
}
|
||||
|
||||
export interface SecurityAlertEntityConfig {
|
||||
entity: string;
|
||||
color?: string;
|
||||
pulse?: boolean;
|
||||
visibility?: Condition[];
|
||||
}
|
||||
|
||||
export interface SecurityFrontendSystemData {
|
||||
alert_entities?: SecurityAlertEntityConfig[];
|
||||
favorite_entities?: string[];
|
||||
}
|
||||
|
||||
export interface EnergyFrontendSystemData {
|
||||
// Stable "<view>.<card-type>" keys of energy dashboard cards the user has
|
||||
// hidden. An absent key or array means nothing is hidden (all cards visible),
|
||||
@@ -64,7 +51,6 @@ declare global {
|
||||
core: CoreFrontendSystemData;
|
||||
home: HomeFrontendSystemData;
|
||||
energy: EnergyFrontendSystemData;
|
||||
security: SecurityFrontendSystemData;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-3
@@ -126,11 +126,18 @@ export const listDatadisks = async (
|
||||
timeout: null,
|
||||
});
|
||||
|
||||
export const fetchHostDisksUsage = async (hass: HomeAssistant) =>
|
||||
// `disk` is "default" for the data disk, or a mount name. Omitting maxDepth
|
||||
// leaves the depth to the Supervisor, which defaults per target — walking a
|
||||
// mount costs a round trip per directory, so mounts want no depth at all.
|
||||
export const fetchHostDisksUsage = async (
|
||||
hass: HomeAssistant,
|
||||
disk = "default",
|
||||
maxDepth?: number
|
||||
) =>
|
||||
hass.callWS<HostDisksUsage>({
|
||||
type: "supervisor/api",
|
||||
endpoint: "/host/disks/default/usage",
|
||||
endpoint: `/host/disks/${disk}/usage`,
|
||||
method: "get",
|
||||
timeout: 3600, // seconds. This can take a while
|
||||
params: { max_depth: 3 },
|
||||
...(maxDepth === undefined ? {} : { params: { max_depth: maxDepth } }),
|
||||
});
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ export const getLogbookDataForContext = async (
|
||||
): Promise<LogbookEntry[]> =>
|
||||
getLogbookDataFromServer(hass, startDate, undefined, undefined, contextId);
|
||||
|
||||
const getLogbookDataFromServer = (
|
||||
export const getLogbookDataFromServer = (
|
||||
hass: HomeAssistant,
|
||||
startDate: string,
|
||||
endDate?: string,
|
||||
|
||||
@@ -2,12 +2,15 @@ import type { Connection } from "home-assistant-js-websocket";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LovelaceResource } from "../resource";
|
||||
import type { LovelaceStrategyConfig } from "./strategy";
|
||||
import type { LovelaceViewRawConfig } from "./view";
|
||||
import type {
|
||||
LovelaceDashboardBackgroundConfig,
|
||||
LovelaceViewRawConfig,
|
||||
} from "./view";
|
||||
|
||||
export interface LovelaceDashboardBaseConfig {}
|
||||
|
||||
export interface LovelaceConfig extends LovelaceDashboardBaseConfig {
|
||||
background?: string;
|
||||
background?: LovelaceDashboardBackgroundConfig;
|
||||
views: LovelaceViewRawConfig[];
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ export interface LovelaceViewBackgroundConfig {
|
||||
attachment?: "scroll" | "fixed";
|
||||
}
|
||||
|
||||
export type LovelaceDashboardBackgroundConfig =
|
||||
string | LovelaceViewBackgroundConfig;
|
||||
|
||||
export interface LovelaceViewHeaderConfig {
|
||||
card?: LovelaceCardConfig;
|
||||
layout?: "start" | "center" | "responsive";
|
||||
@@ -60,7 +63,7 @@ export interface LovelaceBaseViewConfig {
|
||||
show_icon_and_title?: boolean;
|
||||
theme?: string;
|
||||
panel?: boolean;
|
||||
background?: string | LovelaceViewBackgroundConfig;
|
||||
background?: LovelaceDashboardBackgroundConfig;
|
||||
visible?: boolean | ShowViewConfig[];
|
||||
subview?: boolean;
|
||||
back_path?: string;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user