Compare commits

..
253 changed files with 1783 additions and 8314 deletions

No files matched your search

-4
View File
@@ -12,7 +12,3 @@
demo/public/api/camera_proxy_stream/* binary
demo/public/api/media_player_proxy/* binary
# Generated by `gh actions-lock`. Highlight it as YAML and don't auto-merge it:
# on a conflict, restore it from the base branch and run `gh actions-lock`.
.github/workflows/actions.lock linguist-language=YAML -merge
-6
View File
@@ -1,6 +0,0 @@
paths:
.github/workflows/**/*.{yml,yaml}:
ignore:
# actionlint doesn't support `$/` self-repository actions yet, remove once it does:
# https://github.com/rhysd/actionlint/issues/711
- 'specifying action "\$/[^"]+" in invalid format because ref is missing'
@@ -24,7 +24,7 @@ runs:
- name: Setup Node and install
if: steps.dependencies.outputs.cache-hit != 'true'
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
cache: false
-41
View File
@@ -1,41 +0,0 @@
name: Verify actions lockfile
on:
push:
branches:
- dev
- master
paths:
- ".github/actions/**"
- ".github/workflows/**"
pull_request:
branches:
- dev
- master
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
name: Verify actions lockfile
runs-on: ubuntu-latest
env:
GH_ACTIONS_LOCK_VERSION: v0.4.1
steps:
- name: Check out files from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install gh-actions-lock
env:
GH_TOKEN: ${{ github.token }}
run: gh extension install github/gh-actions-lock --pin "${GH_ACTIONS_LOCK_VERSION}"
- name: Verify lockfile
env:
GH_TOKEN: ${{ github.token }}
run: gh actions-lock --verify
-190
View File
@@ -1,190 +0,0 @@
# This file is machine-generated by `gh actions-lock`.
# Do not edit by hand; run `gh actions-lock` to update.
# Docs: https://gh.io/actions-lockfile
version: 'v0.0.3'
workflows:
'.github/workflows/actionlint.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
'.github/workflows/actions-lock.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
'.github/workflows/cast_deployment.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/ci.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
'.github/workflows/codeql-analysis.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'github/codeql-action@9b3254085494b55a0f7c4c2b3d2d52cc712ce370'
'.github/workflows/copilot-setup-steps.yml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/demo_deployment.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/dependency-review.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
'.github/workflows/design_deployment.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/design_preview.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/e2e.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
'.github/workflows/labeler.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
- 'actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13'
'.github/workflows/lock.yaml':
- 'dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c'
'.github/workflows/nightly.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
'.github/workflows/pull-request-standards.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
'.github/workflows/relative-ci.yaml':
- 'relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60'
'.github/workflows/release-drafter.yaml':
- 'release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0'
'.github/workflows/release.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
- 'home-assistant/actions@86d4564712fa02543c808e3a693dd04f9d3f8361'
- 'home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716'
- 'pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086'
'.github/workflows/restrict-task-creation.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
'.github/workflows/stale.yaml':
- 'actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93'
'.github/workflows/sync-device-classes.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1'
'.github/workflows/sync-sensor-entity-constants.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1'
'.github/workflows/translations.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
dependencies:
'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9':
ref: 'v6.1.0'
commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
owner_id: 44036562
repo_id: 215566462
'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1':
ref: 'v7.0.1'
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
owner_id: 44036562
repo_id: 197814629
'actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294':
ref: 'v5.0.0'
commit: 'sha1-a1d282b36b6f3519aa1f3fc636f609c47dddb294'
owner_id: 44036562
repo_id: 476372928
'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333':
ref: 'v8.0.2'
commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333'
owner_id: 44036562
repo_id: 192626254
'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3':
ref: 'v9.0.0'
commit: 'sha1-3a2844b7e9c422d3c10d287c895573f7108da1b3'
owner_id: 44036562
repo_id: 205262760
'actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13':
ref: 'v7.0.0'
commit: 'sha1-bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13'
owner_id: 44036562
repo_id: 177139928
'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1':
ref: 'v7.1.0'
commit: 'sha1-949feb2413d6458794dcd2491c4babbbce0c15c1'
owner_id: 44036562
repo_id: 189476904
'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0':
ref: 'v7.1.0'
commit: 'sha1-6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
owner_id: 44036562
repo_id: 192625525
'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405':
ref: 'v6.2.0'
commit: 'sha1-a309ff8b426b58ec0e2a45f0f869d46889d02405'
owner_id: 44036562
repo_id: 192625525
'actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93':
ref: 'v11.0.0'
commit: 'sha1-4391f3da665fdf50b6810c1a66712fb9ba21aa93'
owner_id: 44036562
repo_id: 200437066
'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9':
ref: 'v7.0.2'
commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9'
owner_id: 44036562
repo_id: 192625955
'dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c':
ref: 'v6.0.2'
commit: 'sha1-89ae32b08ed1a541efecbab17912962a5e38981c'
owner_id: 7513068
repo_id: 113918130
'github/codeql-action@9b3254085494b55a0f7c4c2b3d2d52cc712ce370':
ref: 'v4.39.1'
commit: 'sha1-9b3254085494b55a0f7c4c2b3d2d52cc712ce370'
owner_id: 9919
repo_id: 259445878
'home-assistant/actions@86d4564712fa02543c808e3a693dd04f9d3f8361':
ref: 'master'
commit: 'sha1-86d4564712fa02543c808e3a693dd04f9d3f8361'
owner_id: 13844975
repo_id: 173703505
'home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716':
ref: '2026.07.0'
commit: 'sha1-9e17ab1ed5c4c79d8b61e29fa63de25ca2710716'
owner_id: 13844975
repo_id: 184234812
'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1':
ref: 'v8.1.1'
commit: 'sha1-5f6978faf089d4d20b00c7766989d076bb2fc7f1'
owner_id: 18365890
repo_id: 197176365
'pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086':
ref: 'v1.14.3'
commit: 'sha1-7245906fb61fa33a13554f9d5fb856fe61758086'
owner_id: 647025
repo_id: 178055147
uses:
- 'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405'
'relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60':
ref: 'v3.2.4'
commit: 'sha1-711f3a8fa1b928e6afde280011110c9b0a224a60'
owner_id: 43522883
repo_id: 398376354
'release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0':
ref: 'v7.10.0'
commit: 'sha1-978bb0b30c5ee8cdd332ced253758157150d43f0'
owner_id: 58292376
repo_id: 139022359
+6 -6
View File
@@ -30,17 +30,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Build Cast
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-cast
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: $/.github/actions/netlify-deploy
uses: ./.github/actions/netlify-deploy
with:
dir: cast/dist
alias: dev
@@ -62,17 +62,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Build Cast
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-cast
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: $/.github/actions/netlify-deploy
uses: ./.github/actions/netlify-deploy
with:
dir: cast/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+5 -5
View File
@@ -31,7 +31,7 @@ jobs:
with:
persist-credentials: false
- name: Prepare dependencies
uses: $/.github/actions/prepare-dependencies
uses: ./.github/actions/prepare-dependencies
lint:
name: Lint and check format
@@ -43,7 +43,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Check for duplicate dependencies
@@ -89,7 +89,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build resources
@@ -110,7 +110,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
# Read-only reuse of the rspack cache written by the nightly (see
@@ -127,7 +127,7 @@ jobs:
restore-keys: |
rspack-cache-${{ runner.os }}-
- name: Build Application
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-app
github-token: ${{ secrets.GITHUB_TOKEN }}
+3 -38
View File
@@ -22,45 +22,10 @@ jobs:
with:
persist-credentials: false
# These steps mirror .github/actions/setup with node-modules-cache enabled.
# Copilot runs this workflow in a context that can't resolve `$/`
# self-repository actions, so the steps are inlined here.
- name: Restore complete dependency tree
id: dependency-cache
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- name: Setup Node and install
uses: ./.github/actions/setup
with:
path: |
node_modules
~/.local/share/pnpm/store
key: >-
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
hashFiles('.nvmrc', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'patches/**') }}
- name: Setup Node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: ".nvmrc"
- name: Enable Corepack
run: corepack enable
- name: Get pnpm store directory
id: pnpm-store
if: steps.dependency-cache.outputs.cache-hit != 'true'
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Cache pnpm store
if: steps.pnpm-store.outputs.path
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-store-${{ runner.os }}-${{ runner.arch }}-
- name: Install dependencies
if: steps.dependency-cache.outputs.cache-hit != 'true'
run: pnpm install --frozen-lockfile
node-modules-cache: true
- name: Skip fetching nightly translations
run: echo "SKIP_FETCH_NIGHTLY_TRANSLATIONS=1" >> "$GITHUB_ENV"
+6 -6
View File
@@ -31,17 +31,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Build Demo
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-demo
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: $/.github/actions/netlify-deploy
uses: ./.github/actions/netlify-deploy
with:
dir: demo/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
@@ -62,17 +62,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Build Demo
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-demo
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: $/.github/actions/netlify-deploy
uses: ./.github/actions/netlify-deploy
with:
dir: demo/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+3 -3
View File
@@ -25,17 +25,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Build Gallery
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: $/.github/actions/netlify-deploy
uses: ./.github/actions/netlify-deploy
with:
dir: gallery/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+3 -3
View File
@@ -29,17 +29,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Build Gallery
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy preview to Netlify
id: deploy
uses: $/.github/actions/netlify-deploy
uses: ./.github/actions/netlify-deploy
with:
dir: gallery/dist
alias: deploy-preview-${{ github.event.number }}
+16 -16
View File
@@ -32,13 +32,13 @@ jobs:
persist-credentials: false
- name: Prepare dependencies
uses: $/.github/actions/prepare-dependencies
uses: ./.github/actions/prepare-dependencies
prepare-container-dependencies:
name: Prepare container dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001
defaults:
run:
@@ -50,7 +50,7 @@ jobs:
persist-credentials: false
- name: Prepare dependencies
uses: $/.github/actions/prepare-dependencies
uses: ./.github/actions/prepare-dependencies
with:
node-modules-cache-key: node-modules-container-v1
@@ -66,12 +66,12 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build demo
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-demo-e2e
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -97,12 +97,12 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build e2e test app
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-e2e-test-app-e2e
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -128,12 +128,12 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build gallery
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -155,7 +155,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -176,7 +176,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
node-modules-cache-key: node-modules-container-v1
@@ -207,7 +207,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -230,7 +230,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
node-modules-cache-key: node-modules-container-v1
@@ -261,7 +261,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -284,7 +284,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
node-modules-cache-key: node-modules-container-v1
@@ -326,7 +326,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
+2 -2
View File
@@ -31,7 +31,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
immutable: false
@@ -124,7 +124,7 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
immutable: false
+2 -2
View File
@@ -39,7 +39,7 @@ jobs:
uses: home-assistant/actions/helpers/verify-version@86d4564712fa02543c808e3a693dd04f9d3f8361 # master
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
immutable: false
cache: false
@@ -146,7 +146,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
immutable: false
cache: false
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Regenerate device class constants
run: ./script/gen_device_classes
@@ -28,7 +28,7 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Regenerate sensor entity constants
run: ./script/gen_sensor_entity_constants
-3
View File
@@ -284,7 +284,6 @@ module.exports.config = {
name: "demo" + nameSuffix(latestBuild),
entry: {
main: path.resolve(paths.demo_dir, "src/entrypoint.ts"),
embed: path.resolve(paths.demo_dir, "src/embed/entrypoint.ts"),
...module.exports.mapWorkerEntry(),
},
outputPath: outputPath(paths.demo_output_root, latestBuild),
@@ -292,8 +291,6 @@ module.exports.config = {
defineOverlay: {
__VERSION__: JSON.stringify(`DEMO-${env.version()}`),
__DEMO__: true,
// The embed sets the global to load static files from its own server.
__STATIC_PATH__: '(globalThis.__HA_STATIC_PATH__ || "/static/")',
},
isProdBuild,
latestBuild,
+1 -5
View File
@@ -232,11 +232,7 @@ gulp.task(
)
);
const DEMO_PAGE_ENTRIES = {
"index.html": ["main"],
"components.html": ["embed"],
"embed.js": ["embed"],
};
const DEMO_PAGE_ENTRIES = { "index.html": ["main"] };
gulp.task(
"gen-pages-demo-dev",
+8 -18
View File
@@ -214,18 +214,16 @@ const createRspackConfig = ({
},
},
{
// MapLibre's worker loads ESM plugins through `import(url)`, which
// is never taken here: the RTL plugin is UMD. Rspack's stub for that
// fully dynamic request is exactly what should remain - rejecting,
// and free of syntax the legacy floor cannot parse - so the warning
// about it is noise. The page side builds a blob import from
// `new URL(url, import.meta.url)` for a worker on another origin,
// as in the demo embed. That is a URL, not an asset to bundle, and
// the worker URL is absolute, so leave it as it is.
// MapLibre's worker loads ESM plugins through `import(url)`, and the
// page side builds a blob import from `new URL(url, import.meta.url)`
// for a cross-origin worker. Neither path is taken here: the RTL
// plugin is UMD and the worker is served from the page's origin.
// Rspack's stubs for these fully dynamic requests are exactly what
// should remain - rejecting, and free of syntax the legacy floor
// cannot parse - so the warnings about them are noise.
test: /[\\/]maplibre-gl[\\/]dist[\\/]maplibre-gl(?:-worker)?\.mjs$/,
parser: {
exprContextCritical: false,
url: false,
},
},
{
@@ -248,15 +246,7 @@ const createRspackConfig = ({
},
],
},
// The demo embed reads its own URL at runtime to find its files.
// Keep import.meta.url instead of replacing it with the source path.
// Development builds wrap modules in eval(), which rejects import.meta.
latestBuild &&
isProdBuild && {
test: path.resolve(paths.demo_dir, "src/embed/public-path.ts"),
parser: { importMeta: false },
},
].filter(Boolean),
],
},
optimization: {
minimizer: [
-7
View File
@@ -16,10 +16,3 @@
/frontend_latest/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Access-Control-Allow-Origin: *
/embed.js
Access-Control-Allow-Origin: *
/static/*
Access-Control-Allow-Origin: *
-4
View File
@@ -1,4 +0,0 @@
import { createContext } from "@lit/context";
import type { HomeAssistant } from "../../../src/types";
export const embedHassContext = createContext<HomeAssistant>("embedHass");
-25
View File
@@ -1,25 +0,0 @@
import "./public-path";
import "./workers";
// The provider must be defined first. Custom elements upgrade in the order
// they are defined, and cards need a provider to request their context from.
import "./ha-embed-provider";
import "./ha-embed-card";
// For custom cards, which can't load them themselves. The built-in cards only
// load ha-list-item-button with the map card. The full app loads ha-select
// with the more info dialog.
import "../../../src/components/item/ha-list-item-button";
import "../../../src/components/ha-select";
import { fontStyles } from "../../../src/resources/roboto";
// Font faces only work in the document, not in the shadow root of the card.
// Defining Roboto does not change the page unless it already uses Roboto.
document.head.append(
Object.assign(document.createElement("style"), {
textContent: fontStyles.cssText,
})
);
// Custom cards use the helpers to create built-in cards. The dashboard panel
// sets the same global in the full app.
(window as any).loadCardHelpers = () =>
import("../../../src/panels/lovelace/custom-card-helpers");
-218
View File
@@ -1,218 +0,0 @@
import { ContextEvent } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { createRef, ref } from "lit/directives/ref";
import { consume } from "../../../src/common/decorators/consume";
import { computeRTLDirection } from "../../../src/common/util/compute_rtl";
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
import { handleAction } from "../../../src/panels/lovelace/common/handle-action";
import { computeCardGridSize } from "../../../src/panels/lovelace/common/compute-card-grid-size";
import "../../../src/panels/lovelace/cards/hui-card";
import type { HuiCard } from "../../../src/panels/lovelace/cards/hui-card";
import type { HomeAssistant } from "../../../src/types";
import { embedHassContext } from "./context";
import { readScriptData } from "./script-data";
import { embedThemeStyles } from "./theme-styles";
const pageProvider = (): HTMLElement =>
document.querySelector("ha-embed-provider") ??
document.body.appendChild(document.createElement("ha-embed-provider"));
// Looks past shadow roots, so a card inside another component still finds
// the provider around that component.
const insideProvider = (element: Element): boolean => {
let current: Element | null = element;
while (current) {
if (current.closest("ha-embed-provider")) {
return true;
}
const root = current.getRootNode();
current = root instanceof ShadowRoot ? root.host : null;
}
return false;
};
/**
* Renders a Lovelace card. The card uses the provider with the ID in
* `provider`, else the closest provider around it, else the first provider on
* the page. If the page has no provider, the card adds an empty one.
*
* The card takes the columns and rows it would take in a section, so cards
* in an element with `display: grid` and 12 columns lay out like a section.
* It also sets `--column-size` and `--row-size` on itself.
*/
@customElement("ha-embed-card")
export class HaEmbedCard extends LitElement {
/**
* The card configuration. Also read from a JSON or YAML script child, see
* `readScriptData`.
*/
@property({ type: Object }) public config?: LovelaceCardConfig;
/** The ID of the `ha-embed-provider` to use. */
@property() public provider?: string;
@state()
@consume({ context: embedHassContext, subscribe: true })
private _hass?: HomeAssistant;
// A provider that is not an ancestor, which gets the context requests of the
// card and its content. Context requests only reach ancestors by themselves.
private _linkedProvider?: HTMLElement;
private _scriptRead = false;
private _card = createRef<HuiCard>();
constructor() {
super();
this.addEventListener("context-request", (ev) => {
if (!this._linkedProvider) {
return;
}
ev.stopPropagation();
this._linkedProvider.dispatchEvent(
new ContextEvent(
ev.context,
ev.contextTarget,
ev.callback,
ev.subscribe
)
);
});
// Cards fire their tap, hold and double tap actions for the app to handle
this.addEventListener("hass-action", (ev) => {
if (this._hass) {
// The element that fired the action, so that fire-dom-event reaches
// listeners inside custom cards, as in action-mixin
const node = (ev.composedPath()[0] as HTMLElement | undefined) ?? this;
handleAction(node, this._hass, ev.detail.config, ev.detail.action);
}
});
}
public connectedCallback() {
// Before super.connectedCallback(), which sends the context requests.
this._linkedProvider = this.provider
? (document.getElementById(this.provider) ?? undefined)
: insideProvider(this)
? undefined
: pageProvider();
super.connectedCallback();
// Only once, so that moving the card keeps a config set since
if (this._scriptRead) {
return;
}
this._scriptRead = true;
const config = readScriptData(this) as LovelaceCardConfig | undefined;
if (config) {
this.config = config;
}
}
protected render() {
if (!this._hass || !this.config) {
return nothing;
}
return html`<div
class=${classMap({ theme: true, dark: this._hass.themes.darkMode })}
dir=${computeRTLDirection(this._hass)}
>
<hui-card
${ref(this._card)}
.hass=${this._hass}
.config=${this.config}
.layout=${"grid"}
@card-updated=${this._updateSize}
@card-visibility-changed=${this._updateSize}
></hui-card>
</div>`;
}
protected updated() {
// The card element only exists once hui-card has updated
this._card.value?.updateComplete.then(() => this._updateSize());
}
// The size the card gets in a section, set on the host so that the page can
// lay out cards in a grid of 12 columns, like a section
private _updateSize = () => {
const card = this._card.value;
if (!card) {
return;
}
const { rows, columns } = computeCardGridSize(card.getGridOptions());
this.hidden = card.hidden;
this.toggleAttribute("full-width", columns === "full");
this.toggleAttribute("fit-rows", typeof rows === "number");
this.style.setProperty(
"--column-size",
typeof columns === "number" ? String(columns) : null
);
this.style.setProperty(
"--row-size",
typeof rows === "number" ? String(rows) : null
);
};
static styles = [
embedThemeStyles,
css`
/* The sizes of a card in a section, see hui-grid-section */
:host {
--row-gap: var(--ha-section-grid-row-gap, 8px);
--row-height: var(--ha-section-grid-row-height, 56px);
display: block;
grid-row: span var(--row-size, 1);
grid-column: span min(var(--column-size, 12), 12);
}
:host([full-width]) {
grid-column: 1 / -1;
}
:host([hidden]) {
display: none;
}
:host([fit-rows]) .theme {
height: calc(
(var(--row-size, 1) * (var(--row-height) + var(--row-gap))) - var(
--row-gap
)
);
}
/* The text styles the app sets on html and body, so that the card does
not inherit the text styles of the page that embeds it. */
.theme {
color: var(--primary-text-color);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-body);
line-height: normal;
letter-spacing: normal;
text-align: start;
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
}
/* What setDirectionStyles sets on the app for a right-to-left language,
here only for the card */
.theme[dir="rtl"] {
--direction: rtl;
--float-start: right;
--float-end: left;
--scale-direction: -1;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-card": HaEmbedCard;
}
}
-293
View File
@@ -1,293 +0,0 @@
import { ContextProvider, provide } from "@lit/context";
import type { HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../src/common/dom/fire_event";
import { listenMediaQuery } from "../../../src/common/dom/media_query";
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
import type { CalendarEventApiData } from "../../../src/data/calendar";
import { reducedMotionContext } from "../../../src/data/context";
import type { EnergyInfo, EnergyPreferences } from "../../../src/data/energy";
import { EMPTY_PREFERENCES } from "../../../src/data/energy";
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
import type { EntityInput } from "../../../src/fake_data/entities/types";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { provideHass } from "../../../src/fake_data/provide_hass";
import type { DemoArea } from "../stubs/area_registry";
import { toAreaRegistryEntry } from "../stubs/area_registry";
import { mockAuth } from "../stubs/auth";
import {
FAMILY_EVENTS,
mockCalendarEvents,
WORK_EVENTS,
} from "../stubs/calendar";
import { extEntry, newEntry } from "../stubs/entity_registry";
import { mockHistory } from "../stubs/history";
import { mockIcons } from "../stubs/icons";
import { mockLogbook } from "../stubs/logbook";
import { mockMediaPlayer } from "../stubs/media_player";
import { mockRecorder } from "../stubs/recorder";
import { mockSensor } from "../stubs/sensor";
import { mockExtractFromTarget } from "../stubs/target";
import { mockTemplate } from "../stubs/template";
import { mockTodo } from "../stubs/todo";
import { mockWeather } from "../stubs/weather";
import { embedHassContext } from "./context";
import { readScriptData } from "./script-data";
/** The data of a provider, as set by its script child. */
interface EmbedProviderData {
entities?: EntityInput[];
areas?: DemoArea[];
calendars?: Record<string, CalendarEventApiData[]>;
energy?: Partial<EnergyPreferences>;
}
const SAMPLE_CALENDAR_EVENTS = [FAMILY_EVENTS, WORK_EVENTS];
// "living_room" becomes "Living room"
const areaName = (areaId: string) => {
const name = areaId.replaceAll("_", " ");
return name.charAt(0).toUpperCase() + name.slice(1);
};
/**
* Holds a mocked Home Assistant backend for all `ha-embed-card` elements
* inside it. Cards in the same provider share their entities.
*/
@customElement("ha-embed-provider")
export class HaEmbedProvider extends LitElement {
@provide({ context: embedHassContext })
@property({ attribute: false })
public hass!: MockHomeAssistant;
/**
* All entities of the backend. Each change replaces all entities.
* Also read from a JSON or YAML script child, see `readScriptData`. The
* script holds either the list of entities or an object with `entities`,
* `areas`, `calendars` and `energy`.
*/
@property({ type: Array }) public entities: EntityInput[] = [];
/** All areas. Without them, an area for each `area_id` of the entities. */
@property({ attribute: false }) public areas?: DemoArea[];
/**
* Events of calendar entities by entity ID. Calendars without events here
* get sample events around today.
*/
@property({ attribute: false }) public calendars?: Record<
string,
CalendarEventApiData[]
>;
/** Energy preferences. Without them, energy is not set up. */
@property({ attribute: false }) public energy?: Partial<EnergyPreferences>;
/** Light or dark mode of the cards. Without it, the browser setting. */
@property({ attribute: "color-scheme" }) public colorScheme?:
"light" | "dark";
private _reducedMotion = new ContextProvider(this, {
context: reducedMotionContext,
initialValue: false,
});
private _prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
private _unsubMediaQueries: (() => void)[] = [];
private _scriptRead = false;
private _applyColorScheme() {
const dark = this.colorScheme
? this.colorScheme === "dark"
: this._prefersDark;
if (this.hass.themes.darkMode !== dark) {
this.hass.updateHass({
themes: { ...this.hass.themes, darkMode: dark },
selectedTheme: { ...this.hass.selectedTheme!, dark },
});
}
}
constructor() {
super();
// Set up in the constructor so that the contexts exist before the
// child cards connect and request them.
const hass = provideHass(this);
hass.updateTranslations(null);
hass.updateTranslations("lovelace");
mockIcons(hass);
mockAuth(hass);
mockHistory(hass);
mockRecorder(hass);
mockTemplate(hass);
mockSensor(hass);
mockMediaPlayer(hass);
mockTodo(hass);
mockWeather(hass);
mockLogbook(hass);
hass.updateHass({
config: {
...hass.config,
components: [...hass.config.components, "logbook"],
},
});
mockCalendarEvents(hass, (entityId, currentHass) => {
if (!(entityId in currentHass.states)) {
return null;
}
const calendarIds = Object.keys(currentHass.states).filter((id) =>
id.startsWith("calendar.")
);
return (
this.calendars?.[entityId] ??
SAMPLE_CALENDAR_EVENTS[
calendarIds.indexOf(entityId) % SAMPLE_CALENDAR_EVENTS.length
]
);
});
hass.mockWS("config/area_registry/list", (_msg, currentHass) =>
Object.values(currentHass.areas)
);
// The registry of the entities, without settings such as calendar colors
const registryEntries = (currentHass: MockHomeAssistant) =>
Object.values(currentHass.entities).map(
(entity): EntityRegistryEntry => ({
...newEntry({
entity_id: entity.entity_id,
platform: entity.platform ?? "demo",
unique_id: entity.entity_id,
}),
area_id: entity.area_id ?? null,
device_id: entity.device_id ?? null,
})
);
hass.mockWS("config/entity_registry/list", (_msg, currentHass) =>
registryEntries(currentHass)
);
hass.mockWS(
"config/entity_registry/get",
(msg: { entity_id: string }, currentHass: MockHomeAssistant) => {
const entry = registryEntries(currentHass).find(
(item) => item.entity_id === msg.entity_id
);
if (!entry) {
throw new Error(`Entity not found: ${msg.entity_id}`);
}
return extEntry(entry);
}
);
mockExtractFromTarget(hass);
hass.mockWS("energy/get_prefs", (): EnergyPreferences => ({
...EMPTY_PREFERENCES,
...this.energy,
}));
hass.mockWS("energy/info", (): EnergyInfo => ({
cost_sensors: {},
solar_forecast_domains: [],
}));
this._applyColorScheme();
}
public connectedCallback() {
super.connectedCallback();
this._unsubMediaQueries = [
listenMediaQuery("(prefers-color-scheme: dark)", (matches) => {
this._prefersDark = matches;
this._applyColorScheme();
}),
listenMediaQuery("(prefers-reduced-motion: reduce)", (matches) =>
this._reducedMotion.setValue(matches)
),
];
// Only once, so that moving the provider keeps the current entities
if (this._scriptRead) {
return;
}
this._scriptRead = true;
const data = readScriptData(this) as
EntityInput[] | EmbedProviderData | undefined;
if (Array.isArray(data)) {
this.entities = data;
} else if (data) {
this.entities = data.entities ?? [];
this.areas = data.areas;
this.calendars = data.calendars;
this.energy = data.energy;
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubMediaQueries.forEach((unsub) => unsub());
this._unsubMediaQueries = [];
}
protected willUpdate(changedProps: PropertyValues<this>) {
if (changedProps.has("colorScheme")) {
this._applyColorScheme();
}
if (changedProps.has("entities") || changedProps.has("areas")) {
const areas: DemoArea[] =
this.areas ??
[
...new Set(
this.entities
.map((entity) => entity.area_id)
.filter((areaId) => areaId !== undefined)
),
].map((areaId) => ({ area_id: areaId, name: areaName(areaId) }));
const areasById: Record<string, AreaRegistryEntry> = {};
areas.forEach((area) => {
areasById[area.area_id] = toAreaRegistryEntry(area);
});
// Before the entities, so that their names can include the area
this.hass.updateHass({ areas: areasById });
// Entities that services changed keep their state when only the areas
// change
if (changedProps.has("entities")) {
this.hass.addEntities(this.entities, true);
} else {
this.hass.updateFormatFunctions();
}
}
}
protected updated(changedProps: PropertyValues<this>) {
const oldHass = changedProps.get("hass");
// The first update holds the initial entities. They are not changes.
if (!oldHass) {
return;
}
// Other updates, such as loaded translations, keep the same states object.
if (oldHass.states !== this.hass.states) {
fireEvent(this, "states-changed", { states: this.hass.states });
}
}
protected render() {
return html`<slot></slot>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-provider": HaEmbedProvider;
}
interface HASSDomEvents {
"states-changed": { states: HassEntities };
}
}
-20
View File
@@ -1,20 +0,0 @@
/* eslint-disable @typescript-eslint/naming-convention */
declare global {
var __HA_STATIC_PATH__: string | undefined;
}
declare let __webpack_public_path__: string;
/* eslint-enable @typescript-eslint/naming-convention */
// Load chunks and static files from the server that hosts this script, so
// that other sites can embed the cards. Only the modern production build keeps
// `import.meta.url` (see build-scripts/rspack.cjs). The legacy and development
// builds only run on the demo site, where the default paths are correct.
// `new URL(path, import.meta.url)` in one expression is an asset reference for
// the bundler, so the script URL goes into a variable first.
if (__BUILD__ === "modern" && !__DEV__) {
const scriptUrl = import.meta.url;
__webpack_public_path__ = new URL(".", scriptUrl).href;
globalThis.__HA_STATIC_PATH__ = new URL("../static/", scriptUrl).href;
}
export {};
-17
View File
@@ -1,17 +0,0 @@
import { load } from "js-yaml";
/**
* Parses the data in a `<script type="application/json">` or
* `<script type="text/yaml">` child of the element.
*/
export const readScriptData = (element: HTMLElement): unknown => {
const script = element.querySelector<HTMLScriptElement>(
':scope > script[type="application/json"], :scope > script[type="text/yaml"]'
);
if (!script) {
return undefined;
}
return script.type === "text/yaml"
? load(script.textContent!)
: JSON.parse(script.textContent!);
};
-61
View File
@@ -1,61 +0,0 @@
import { unsafeCSS } from "lit";
import { hex2rgb } from "../../../src/common/color/convert-color";
import { darkColorVariables } from "../../../src/resources/theme/color";
import { darkSemanticVariables } from "../../../src/resources/theme/semantic.globals";
import { themeStyles } from "../../../src/resources/theme/theme";
// The theme styles set variables on `html`, next to fonts and other page
// styles. Only the variables, media queries, and keyframes are copied, and
// `html` becomes `selector`. This keeps the theme inside the card, so that it
// does not change the page that embeds the card.
const scopeThemeStyles = (cssText: string, selector: string): string => {
// A style element in a separate document parses the CSS without applying it.
const doc = document.implementation.createHTMLDocument("");
const style = doc.createElement("style");
style.textContent = cssText;
doc.head.append(style);
const convert = (rules: CSSRuleList): string =>
Array.from(rules, (rule) => {
if (rule instanceof CSSStyleRule) {
if (rule.selectorText !== "html") {
return "";
}
const variables = Array.from(rule.style)
.filter((name) => name.startsWith("--"))
.map((name) => `${name}:${rule.style.getPropertyValue(name)};`);
return `${selector}{${variables.join("")}}`;
}
if (rule instanceof CSSMediaRule) {
return `@media ${rule.conditionText}{${convert(rule.cssRules)}}`;
}
if (rule instanceof CSSKeyframesRule) {
return rule.cssText;
}
return "";
}).join("");
return convert(style.sheet!.cssRules);
};
const darkTheme = {
...darkSemanticVariables,
...darkColorVariables,
};
// Like processTheme in apply_themes_on_element, each hex color also sets its
// `--rgb-` variable, else those keep their light theme values
const darkVariables = Object.entries(darkTheme).flatMap(([name, value]) => {
const variable = `--${name}:${value};`;
if (!value.startsWith("#") || `rgb-${name}` in darkTheme) {
return [variable];
}
return [variable, `--rgb-${name}:${hex2rgb(value).join(",")};`];
});
export const embedThemeStyles = unsafeCSS(
scopeThemeStyles(themeStyles, ".theme") +
`.theme.dark{${darkVariables.join("")}}`
);
-32
View File
@@ -1,32 +0,0 @@
// Browsers only start a worker from a script on the page's own origin, and
// the workers of the cards, such as the markdown worker, are served next to
// this script. A worker from another origin starts from a blob instead, which
// loads the real worker script. The demo serves its scripts with CORS.
const pageWorker = globalThis.Worker;
globalThis.Worker = class extends pageWorker {
constructor(scriptUrl: string | URL, options?: WorkerOptions) {
const url = new URL(scriptUrl, location.href);
if (url.origin === location.origin) {
super(scriptUrl, options);
return;
}
const source =
options?.type === "module"
? `import ${JSON.stringify(url.href)};`
: `importScripts(${JSON.stringify(url.href)});`;
const blobUrl = URL.createObjectURL(
new Blob([source], { type: "text/javascript" })
);
try {
super(blobUrl, options);
} finally {
URL.revokeObjectURL(blobUrl);
}
}
};
export {};
-4
View File
@@ -36,7 +36,6 @@ import { mockHardware } from "./stubs/hardware";
import { mockHassioSupervisor } from "./stubs/hassio_supervisor";
import { mockIntegration } from "./stubs/integration";
import { mockLabelRegistry } from "./stubs/label_registry";
import { mockLogbook } from "./stubs/logbook";
import { automationPlatformDomains } from "./stubs/automation_platform_domains";
import { mockIcons } from "./stubs/icons";
import { mockHistory } from "./stubs/history";
@@ -51,7 +50,6 @@ import { mockTemplate } from "./stubs/template";
import { mockTodo } from "./stubs/todo";
import { mockTranslations } from "./stubs/translations";
import { mockUsagePrediction } from "./stubs/usage_prediction";
import { mockWeather } from "./stubs/weather";
// 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.
@@ -192,9 +190,7 @@ export class HaDemo extends HomeAssistantAppEl {
mockSystemLog(hass);
mockTemplate(hass);
mockEvents(hass);
mockLogbook(hass);
mockMediaPlayer(hass);
mockWeather(hass);
mockFrontend(hass);
mockHardware(hass);
mockHassioSupervisor(hass);
-762
View File
@@ -1,762 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Home Assistant card components</title>
<%= renderTemplate("../../../src/html/_header.html.template") %>
<meta name="referrer" content="same-origin" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#03a9f4" />
<style>
:root {
color-scheme: light;
--page-background: #fafafa;
--page-text: #212121;
--page-code-background: #e5e5e5;
--page-divider: #dddddd;
}
@media (prefers-color-scheme: dark) {
:root:not([data-scheme="light"]) {
color-scheme: dark;
--page-background: #111111;
--page-text: #e1e1e1;
--page-code-background: #1c1c1c;
--page-divider: #333333;
}
}
:root[data-scheme="dark"] {
color-scheme: dark;
--page-background: #111111;
--page-text: #e1e1e1;
--page-code-background: #1c1c1c;
--page-divider: #333333;
}
html {
background-color: var(--page-background);
color: var(--page-text);
}
body {
font-family: Roboto, Noto, sans-serif;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
line-height: 1.5;
margin: 0;
padding: 0 16px 64px;
}
main {
max-width: 880px;
margin: 0 auto;
}
h1 {
margin: 32px 0 8px;
}
h2 {
margin: 48px 0 8px;
}
pre {
background: var(--page-code-background);
border-radius: 8px;
padding: 12px 16px;
overflow-x: auto;
font-size: 13px;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
text-align: start;
vertical-align: top;
padding: 8px;
border-bottom: 1px solid var(--page-divider);
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
align-items: start;
margin: 16px 0;
}
.single {
max-width: 400px;
margin: 16px 0;
}
.log {
height: 160px;
overflow-y: auto;
margin: 0;
}
button {
font: inherit;
padding: 6px 12px;
margin-inline-end: 8px;
cursor: pointer;
}
</style>
</head>
<body>
<main>
<h1>Home Assistant card components</h1>
<p>
Embed working Home Assistant dashboard cards in any page. The cards run
against a mocked backend in the browser. No Home Assistant server is
necessary.
</p>
<p>There are two components:</p>
<ul>
<li>
<code>&lt;ha-embed-provider&gt;</code> holds the mocked backend and
its entities.
</li>
<li>
<code>&lt;ha-embed-card&gt;</code> renders one card. It uses the
backend of a provider.
</li>
</ul>
<p>
All cards in the same provider share their entities. When you change
an entity in one card, the other cards show the change.
</p>
<p>
Color scheme of this page and its cards:
<button data-scheme="">Auto</button>
<button data-scheme="light">Light</button>
<button data-scheme="dark">Dark</button>
</p>
<h2>Use on your site</h2>
<p>
Load the script from the demo site. The script loads all other files
from the demo site too.
</p>
<pre><code>&lt;script type="module" src="https://demo.home-assistant.io/embed.js"&gt;&lt;/script&gt;</code></pre>
<h2>One card</h2>
<p>
Put the entities in a script tag inside the provider. Put the card
configuration in a script tag inside the card. Both take JSON with
<code>type="application/json"</code> or YAML with
<code>type="text/yaml"</code>. The card configuration is the same as in
a dashboard. YAML needs its indentation. If your site minifies HTML,
make sure the minifier keeps the whitespace in script tags, or use
JSON.
</p>
<pre><code>&lt;ha-embed-provider&gt;
&lt;script type="text/yaml"&gt;
- entity_id: light.kitchen
state: "on"
attributes:
friendly_name: Kitchen
supported_color_modes: [brightness]
color_mode: brightness
brightness: 180
&lt;/script&gt;
&lt;ha-embed-card&gt;
&lt;script type="text/yaml"&gt;
type: tile
entity: light.kitchen
tap_action:
action: toggle
features:
- type: light-brightness
&lt;/script&gt;
&lt;/ha-embed-card&gt;
&lt;/ha-embed-provider&gt;</code></pre>
<ha-embed-provider>
<!-- htmlmin:ignore --><script type="text/yaml">
- entity_id: light.kitchen
state: "on"
attributes:
friendly_name: Kitchen
supported_color_modes: [brightness]
color_mode: brightness
brightness: 180
</script><!-- htmlmin:ignore -->
<div class="single">
<ha-embed-card>
<!-- htmlmin:ignore --><script type="text/yaml">
type: tile
entity: light.kitchen
tap_action:
action: toggle
features:
- type: light-brightness
</script><!-- htmlmin:ignore -->
</ha-embed-card>
</div>
</ha-embed-provider>
<p>
The <code>entities</code> attribute of the provider and the
<code>config</code> attribute of the card also take JSON.
</p>
<h2>Cards with shared state</h2>
<p>
These three cards are in one provider. Turn on a light in one card. The
other cards update. The log below shows the events of the provider.
</p>
<ha-embed-provider id="shared">
<script type="application/json">
[
{
"entity_id": "light.living_room",
"state": "off",
"attributes": {
"friendly_name": "Living room",
"supported_color_modes": ["brightness"],
"color_mode": "brightness",
"brightness": 255
}
},
{
"entity_id": "switch.coffee_maker",
"state": "on",
"attributes": {
"friendly_name": "Coffee maker",
"device_class": "outlet"
}
},
{
"entity_id": "climate.hallway",
"state": "heat",
"attributes": {
"friendly_name": "Hallway",
"current_temperature": 19.5,
"temperature": 21,
"min_temp": 7,
"max_temp": 30,
"target_temp_step": 0.5,
"hvac_modes": ["heat", "off"],
"hvac_action": "heating",
"supported_features": 1
}
},
{
"entity_id": "sensor.outside_temperature",
"state": "12.3",
"attributes": {
"friendly_name": "Outside temperature",
"device_class": "temperature",
"state_class": "measurement",
"unit_of_measurement": "°C"
}
}
]
</script>
<div class="cards">
<ha-embed-card>
<script type="application/json">
{
"type": "tile",
"entity": "light.living_room",
"tap_action": { "action": "toggle" }
}
</script>
</ha-embed-card>
<ha-embed-card>
<!-- htmlmin:ignore --><script type="text/yaml">
type: entities
title: Living room
entities:
- light.living_room
- switch.coffee_maker
- sensor.outside_temperature
</script><!-- htmlmin:ignore -->
</ha-embed-card>
<ha-embed-card>
<!-- htmlmin:ignore --><script type="text/yaml">
type: thermostat
entity: climate.hallway
</script><!-- htmlmin:ignore -->
</ha-embed-card>
</div>
</ha-embed-provider>
<pre class="log" id="log"><code>Use the cards to see events here.</code></pre>
<h2>Cards outside a provider</h2>
<p>
A card does not have to be inside a provider. A card outside a provider
uses the first provider on the page. Put one provider with all entities
at the top of the page, and put the cards anywhere below it. If the
page has no provider, the card adds an empty provider to the page.
</p>
<p>
To use a different provider, set the <code>provider</code> attribute of
the card to the ID of the provider.
</p>
<pre><code>&lt;ha-embed-provider id="living-room"&gt;…&lt;/ha-embed-provider&gt;
&lt;p&gt;Some text about the coffee maker.&lt;/p&gt;
&lt;ha-embed-card provider="living-room"&gt;
&lt;script type="text/yaml"&gt;
type: tile
entity: switch.coffee_maker
&lt;/script&gt;
&lt;/ha-embed-card&gt;</code></pre>
<p>
These two cards are not inside a provider. The first card uses the
first provider on this page, the kitchen light at the top. The second
card uses the provider with the ID <code>shared</code> above.
</p>
<div class="cards">
<ha-embed-card>
<!-- htmlmin:ignore --><script type="text/yaml">
type: tile
entity: light.kitchen
tap_action:
action: toggle
</script><!-- htmlmin:ignore -->
</ha-embed-card>
<ha-embed-card provider="shared">
<!-- htmlmin:ignore --><script type="text/yaml">
type: tile
entity: switch.coffee_maker
tap_action:
action: toggle
</script><!-- htmlmin:ignore -->
</ha-embed-card>
</div>
<h2>Light and dark mode</h2>
<p>
The cards follow the light or dark mode of the browser. To choose the
mode, set the <code>color-scheme</code> attribute of the provider to
<code>light</code> or <code>dark</code>. Use this when your site has its
own switch for dark mode. The buttons at the top of this page do this.
The cards keep their styles inside. They do not change the styles of
your page.
</p>
<pre><code>&lt;ha-embed-provider color-scheme="dark"&gt;…&lt;/ha-embed-provider&gt;</code></pre>
<h2>Change entities from the page</h2>
<p>
Set the <code>entities</code> property to change entities from
JavaScript. The new list replaces all entities. To change one entity,
start from the current states.
</p>
<p>
<button id="set-temperature">Set a random outside temperature</button>
<button id="toggle-coffee">Toggle the coffee maker</button>
</p>
<pre><code>const provider = document.querySelector("ha-embed-provider");
provider.entities = Object.values(provider.hass.states).map((entity) =&gt;
entity.entity_id === "sensor.outside_temperature"
? { ...entity, state: "18.1" }
: entity
);</code></pre>
<p>
To use the actions of an entity, call a service. The provider updates
the state and the cards.
</p>
<pre><code>provider.hass.callService("switch", "toggle", {
entity_id: "switch.coffee_maker",
});</code></pre>
<h2>Listen to changes</h2>
<p>
The provider fires a <code>states-changed</code> event when the state
of one or more entities changes. The event has the states of all
entities. The event bubbles.
</p>
<pre><code>provider.addEventListener("states-changed", (ev) =&gt; {
const { states } = ev.detail;
console.log(states["light.living_room"].state);
});</code></pre>
<p>
Read the current state of all entities with
<code>provider.hass.states</code>.
</p>
<h2>Areas, calendars and energy</h2>
<p>
Some cards need data that is not an entity. To set it, put an object in
the script tag of the provider instead of the list. The object has the
list in <code>entities</code>, and any of these keys:
</p>
<ul>
<li>
<code>areas</code>: a list of areas, each with an
<code>area_id</code> and a <code>name</code>. Without it, each
<code>area_id</code> of the entities becomes an area.
</li>
<li>
<code>calendars</code>: the events of calendar entities, by entity
ID. Each event has a <code>summary</code>, a <code>start</code> and
an <code>end</code>. Calendars without events here get sample events
around today.
</li>
<li>
<code>energy</code>: the energy settings, the same as the
<code>energy/get_prefs</code> response. Without it, energy is not
set up.
</li>
</ul>
<p>
Weather entities get a forecast made up from their condition and
temperature, or their <code>forecast</code> attribute when they have
one. Set <code>supported_features</code> to show forecasts.
</p>
<pre><code>&lt;ha-embed-provider&gt;
&lt;script type="text/yaml"&gt;
entities:
- entity_id: calendar.family
state: "off"
attributes:
friendly_name: Family
areas:
- area_id: kitchen
name: Kitchen
calendars:
calendar.family:
- summary: Dentist
start: "2026-11-02T15:30:00+00:00"
end: "2026-11-02T16:15:00+00:00"
&lt;/script&gt;
…
&lt;/ha-embed-provider&gt;</code></pre>
<p>This calendar card uses the sample events.</p>
<ha-embed-provider>
<script type="application/json">
{
"entities": [
{
"entity_id": "calendar.family",
"state": "off",
"attributes": { "friendly_name": "Family" }
}
]
}
</script>
<div class="single">
<ha-embed-card
config='{ "type": "calendar", "entities": ["calendar.family"], "initial_view": "listWeek" }'
></ha-embed-card>
</div>
</ha-embed-provider>
<h2>Custom cards</h2>
<p>
Custom cards work too. Load the script of the custom card on the page,
and use <code>custom:</code> and the element name as the card type.
The custom card gets the same mocked backend as the other cards.
<code>window.loadCardHelpers()</code> is available to create built-in
cards.
</p>
<p>
The mocked backend only knows a part of the Home Assistant API. History
and statistics are made up from the current state of each entity.
Templates are not rendered, they come back as they are. The logbook is
empty. Other requests get an error. From another site, the script of
the custom card needs CORS headers, like the embed script.
</p>
<pre><code>&lt;script type="module" src="https://example.com/embed-demo-custom-card.js"&gt;&lt;/script&gt;
&lt;ha-embed-card&gt;
&lt;script type="text/yaml"&gt;
type: custom:embed-demo-custom-card
entity: light.office
&lt;/script&gt;
&lt;/ha-embed-card&gt;</code></pre>
<ha-embed-provider>
<script type="application/json">
[
{
"entity_id": "light.office",
"state": "on",
"attributes": {
"friendly_name": "Office",
"supported_color_modes": ["brightness"],
"color_mode": "brightness",
"brightness": 120
}
}
]
</script>
<div class="single">
<ha-embed-card
config='{ "type": "custom:embed-demo-custom-card", "entity": "light.office" }'
></ha-embed-card>
</div>
</ha-embed-provider>
<p>This is the code of the custom card:</p>
<pre><code>class EmbedDemoCustomCard extends HTMLElement {
setConfig(config) {
if (!config.entity) {
throw new Error("Set an entity");
}
this._config = config;
}
set hass(hass) {
this._hass = hass;
this._render();
}
getCardSize() {
return 3;
}
async _render() {
if (!this.shadowRoot) {
this.attachShadow({ mode: "open" }).innerHTML = `
&lt;style&gt;
ha-card { padding: 16px; }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 8px;
background: var(--accent-color);
color: var(--text-accent-color, #fff);
font-size: 12px;
}
.tile { margin: 12px 0; }
&lt;/style&gt;
&lt;ha-card&gt;
&lt;span class="badge"&gt;Custom card&lt;/span&gt;
&lt;p&gt;
This is a custom card. It is not part of Home Assistant. The page
defines it, and it creates the tile card below with the card
helpers.
&lt;/p&gt;
&lt;div class="tile"&gt;&lt;/div&gt;
&lt;button&gt;Toggle from the custom card&lt;/button&gt;
&lt;/ha-card&gt;`;
this.shadowRoot.querySelector("button").addEventListener("click", () =&gt;
this._hass.callService("homeassistant", "toggle", {
entity_id: this._config.entity,
})
);
}
// Keep the promise, so that a second update does not create a second tile.
if (!this._tile) {
this._tile = window.loadCardHelpers().then((helpers) => {
const tile = helpers.createCardElement({
type: "tile",
entity: this._config.entity,
});
this.shadowRoot.querySelector(".tile").append(tile);
return tile;
});
}
(await this._tile).hass = this._hass;
}
}
customElements.define("embed-demo-custom-card", EmbedDemoCustomCard);</code></pre>
<script type="module">
class EmbedDemoCustomCard extends HTMLElement {
setConfig(config) {
if (!config.entity) {
throw new Error("Set an entity");
}
this._config = config;
}
set hass(hass) {
this._hass = hass;
this._render();
}
getCardSize() {
return 3;
}
async _render() {
if (!this.shadowRoot) {
this.attachShadow({ mode: "open" }).innerHTML = `
<style>
ha-card { padding: 16px; }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 8px;
background: var(--accent-color);
color: var(--text-accent-color, #fff);
font-size: 12px;
}
.tile { margin: 12px 0; }
</style>
<ha-card>
<span class="badge">Custom card</span>
<p>
This is a custom card. It is not part of Home Assistant. The page
defines it, and it creates the tile card below with the card
helpers.
</p>
<div class="tile"></div>
<button>Toggle from the custom card</button>
</ha-card>`;
this.shadowRoot.querySelector("button").addEventListener("click", () =>
this._hass.callService("homeassistant", "toggle", {
entity_id: this._config.entity,
})
);
}
// Keep the promise, so that a second update does not create a second tile.
if (!this._tile) {
this._tile = window.loadCardHelpers().then((helpers) => {
const tile = helpers.createCardElement({
type: "tile",
entity: this._config.entity,
});
this.shadowRoot.querySelector(".tile").append(tile);
return tile;
});
}
(await this._tile).hass = this._hass;
}
}
customElements.define("embed-demo-custom-card", EmbedDemoCustomCard);
</script>
<h2>Reference</h2>
<h3><code>&lt;ha-embed-provider&gt;</code></h3>
<table>
<tr>
<th>Name</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td><code>entities</code></td>
<td>Attribute and property</td>
<td>
A list of entities. Each entity has an <code>entity_id</code>, a
<code>state</code>, and <code>attributes</code>. The optional keys
<code>area_id</code>, <code>device_id</code>, and
<code>platform</code> set the entity registry entry. A new list
replaces all entities. A JSON or YAML script child sets the first
list.
</td>
</tr>
<tr>
<td><code>color-scheme</code></td>
<td>Attribute and property (<code>colorScheme</code>)</td>
<td>
<code>light</code> or <code>dark</code>. Without it, the cards
follow the browser setting.
</td>
</tr>
<tr>
<td><code>hass</code></td>
<td>Property, read-only</td>
<td>The mocked Home Assistant object that the cards use.</td>
</tr>
<tr>
<td><code>states-changed</code></td>
<td>Event</td>
<td>
Fires when the state of one or more entities changes. The detail
has <code>states</code>, the states of all entities by entity ID.
</td>
</tr>
</table>
<h3><code>&lt;ha-embed-card&gt;</code></h3>
<table>
<tr>
<th>Name</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td><code>config</code></td>
<td>Attribute and property</td>
<td>
The card configuration, the same as in a dashboard. A JSON or YAML
script child sets the first configuration.
</td>
</tr>
<tr>
<td><code>provider</code></td>
<td>Attribute and property</td>
<td>
The ID of the provider to use. Without it, the card uses the
provider around it, else the first provider on the page.
</td>
</tr>
</table>
<p>
The mocked backend knows the actions of lights, switches, climate,
covers, fans, locks, media players, and more. Actions that open dialogs,
such as <code>more-info</code>, do nothing.
</p>
</main>
<script>
(function () {
var shared = document.getElementById("shared");
var log = document.getElementById("log");
var first = true;
shared.addEventListener("states-changed", function (ev) {
var states = ev.detail.states;
var line =
new Date().toLocaleTimeString() +
" " +
Object.keys(states)
.map(function (entityId) {
return entityId + "=" + states[entityId].state;
})
.join(", ");
if (first) {
log.textContent = "";
first = false;
}
log.textContent = line + "\n" + log.textContent;
});
document
.getElementById("set-temperature")
.addEventListener("click", function () {
var states = shared.hass.states;
shared.entities = Object.keys(states).map(function (entityId) {
var entity = states[entityId];
return {
entity_id: entityId,
state:
entityId === "sensor.outside_temperature"
? (Math.random() * 30 - 5).toFixed(1)
: entity.state,
attributes: entity.attributes,
};
});
});
document
.querySelectorAll("button[data-scheme]")
.forEach(function (button) {
button.addEventListener("click", function () {
var scheme = button.getAttribute("data-scheme");
if (scheme) {
document.documentElement.setAttribute("data-scheme", scheme);
} else {
document.documentElement.removeAttribute("data-scheme");
}
document
.querySelectorAll("ha-embed-provider")
.forEach(function (provider) {
if (scheme) {
provider.setAttribute("color-scheme", scheme);
} else {
provider.removeAttribute("color-scheme");
}
});
});
});
document
.getElementById("toggle-coffee")
.addEventListener("click", function () {
shared.hass.callService("switch", "toggle", {
entity_id: "switch.coffee_maker",
});
});
})();
</script>
<%= renderTemplate("../../../src/html/_js_base.html.template") %>
<%= renderTemplate("../../../src/html/_script_loader.html.template") %>
</body>
</html>
-1
View File
@@ -1 +0,0 @@
import "<%= latestEntryJS[0] %>";
+13 -15
View File
@@ -12,20 +12,6 @@ export interface DemoArea {
let areas: AreaRegistryEntry[] = [];
export const toAreaRegistryEntry = (area: DemoArea): AreaRegistryEntry => ({
area_id: area.area_id,
name: area.name,
floor_id: area.floor_id ?? null,
icon: area.icon ?? null,
temperature_entity_id: area.temperature_entity_id ?? null,
humidity_entity_id: area.humidity_entity_id ?? null,
aliases: [],
labels: [],
picture: null,
created_at: 0,
modified_at: 0,
});
export const mockAreaRegistry = (
hass: MockHomeAssistant,
data: DemoArea[] = []
@@ -39,7 +25,19 @@ export const setDemoAreas = (
hass: MockHomeAssistant,
demoAreas: DemoArea[] = []
) => {
areas = demoAreas.map(toAreaRegistryEntry);
areas = demoAreas.map((area) => ({
area_id: area.area_id,
name: area.name,
floor_id: area.floor_id ?? null,
icon: area.icon ?? null,
temperature_entity_id: area.temperature_entity_id ?? null,
humidity_entity_id: area.humidity_entity_id ?? null,
aliases: [],
labels: [],
picture: null,
created_at: 0,
modified_at: 0,
}));
const areasById: Record<string, AreaRegistryEntry> = {};
areas.forEach((area) => {
areasById[area.area_id] = area;
+127 -2
View File
@@ -2,13 +2,14 @@ import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../../../src/common/array/ensure-array";
import { computeDomain } from "../../../src/common/entity/compute_domain";
import type { ConditionDescriptions } from "../../../src/data/condition";
import type { ExtractFromTargetResult } from "../../../src/data/target";
import type { TriggerDescriptions } from "../../../src/data/trigger";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { getLabelIds } from "./label_registry";
import {
conditionDescriptions,
triggerDescriptions,
} from "./automation_platforms_data";
import { expandTarget, mockExtractFromTarget } from "./target";
type Descriptions = TriggerDescriptions | ConditionDescriptions;
@@ -19,6 +20,109 @@ interface EntityFilter {
supported_features?: number[];
}
// Expands a target to its areas, devices and entities, like core does
const expandTarget = (
hass: MockHomeAssistant,
target: HassServiceTarget,
primaryEntitiesOnly = true
) => {
const missing = {
floors: [] as string[],
areas: [] as string[],
devices: [] as string[],
labels: [] as string[],
};
const knownLabelIds = getLabelIds();
const floorIds = ensureArray(target.floor_id ?? []);
const labelIds = ensureArray(target.label_id ?? []);
missing.floors.push(...floorIds.filter((id) => !(id in hass.floors)));
// Like core, unknown labels are reported, but still expanded
missing.labels.push(...labelIds.filter((id) => !knownLabelIds.includes(id)));
const hasLabel = (labels?: string[]) =>
labelIds.some((labelId) => labels?.includes(labelId));
// Unknown areas and devices are reported, but still referenced
const areaIds = new Set(ensureArray(target.area_id ?? []));
missing.areas.push(...[...areaIds].filter((id) => !(id in hass.areas)));
for (const area of Object.values(hass.areas)) {
if (
(area.floor_id && floorIds.includes(area.floor_id)) ||
hasLabel(area.labels)
) {
areaIds.add(area.area_id);
}
}
// A targeted or labeled device includes its child devices
const deviceIds = new Set<string>();
const addDevice = (deviceId: string) => {
deviceIds.add(deviceId);
for (const device of Object.values(hass.devices)) {
if (device.parent_device_id === deviceId) {
deviceIds.add(device.id);
}
}
};
for (const deviceId of ensureArray(target.device_id ?? [])) {
if (!(deviceId in hass.devices)) {
missing.devices.push(deviceId);
}
addDevice(deviceId);
}
for (const device of Object.values(hass.devices)) {
if (hasLabel(device.labels)) {
addDevice(device.id);
}
}
// A child device without its own area is in the area of its parent device
const deviceArea = (deviceId: string) => {
const device = hass.devices[deviceId];
return (
device?.area_id ??
(device?.parent_device_id
? hass.devices[device.parent_device_id]?.area_id
: undefined)
);
};
const areaDeviceIds = new Set<string>();
for (const device of Object.values(hass.devices)) {
const areaId = deviceArea(device.id);
if (areaId && areaIds.has(areaId)) {
areaDeviceIds.add(device.id);
}
}
const entityIds = new Set(ensureArray(target.entity_id ?? []));
for (const entity of Object.values(hass.entities)) {
// The display registry keeps the entities of a previous demo, and like
// core, hidden entities are never included indirectly
if (!(entity.entity_id in hass.states) || entity.hidden) {
continue;
}
const primary = !primaryEntitiesOnly || !entity.entity_category;
if (
hasLabel(entity.labels) ||
(primary &&
((entity.device_id && deviceIds.has(entity.device_id)) ||
(entity.area_id && areaIds.has(entity.area_id)) ||
// An entity with its own area is not in the area of its device
(!entity.area_id &&
entity.device_id &&
areaDeviceIds.has(entity.device_id))))
) {
entityIds.add(entity.entity_id);
}
}
return {
areas: [...areaIds],
devices: [...new Set([...deviceIds, ...areaDeviceIds])],
entities: [...entityIds],
missing,
};
};
const matchesFilter = (
hass: MockHomeAssistant,
entityId: string,
@@ -124,7 +228,28 @@ export const mockAutomationPlatforms = (hass: MockHomeAssistant) => {
);
mockForTarget(hass, "get_triggers_for_target", triggerDescriptions);
mockForTarget(hass, "get_conditions_for_target", conditionDescriptions);
mockExtractFromTarget(hass);
hass.mockWS(
"extract_from_target",
(
msg: { target: HassServiceTarget; primary_entities_only?: boolean },
currentHass: MockHomeAssistant
): ExtractFromTargetResult => {
const { areas, devices, entities, missing } = expandTarget(
currentHass,
msg.target,
msg.primary_entities_only ?? true
);
return {
missing_areas: missing.areas,
missing_devices: missing.devices,
missing_floors: missing.floors,
missing_labels: missing.labels,
referenced_areas: areas,
referenced_devices: devices,
referenced_entities: entities,
};
}
);
hass.mockWS(
"get_services_for_target",
(msg: { target: HassServiceTarget }, currentHass: MockHomeAssistant) =>
-122
View File
@@ -1,122 +0,0 @@
import type { CalendarEventApiData } from "../../../src/data/calendar";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
// Sample events relative to today, so demos always show something in the
// current week and month.
const pad = (value: number) => String(value).padStart(2, "0");
const day = (offset: number): Date => {
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + offset);
return date;
};
const isoDate = (date: Date) =>
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const at = (offset: number, hours: number, minutes = 0) => {
const date = day(offset);
date.setHours(hours, minutes);
return date.toISOString();
};
const allDay = (offset: number, days = 1) => ({
start: isoDate(day(offset)),
end: isoDate(day(offset + days)),
all_day: true,
});
export const FAMILY_EVENTS: CalendarEventApiData[] = [
{ summary: "School run", start: at(0, 8), end: at(0, 8, 30) },
{
summary: "Dentist",
start: at(0, 15, 30),
end: at(0, 16, 15),
location: "Main Street 12",
},
{ summary: "Grandma's birthday", ...allDay(1) },
{ summary: "Football practice", start: at(2, 17), end: at(2, 18, 30) },
{
summary: "Dinner with friends",
start: at(3, 19),
end: at(3, 22),
status: "tentative",
},
{ summary: "Summer cabin", ...allDay(5, 3) },
{ summary: "Parent-teacher meeting", start: at(9, 18), end: at(9, 19) },
{ summary: "Football practice", start: at(9, 17), end: at(9, 18, 30) },
{ summary: "Swimming lessons", start: at(12, 10), end: at(12, 11) },
{ summary: "Car service", start: at(-3, 9), end: at(-3, 11) },
{ summary: "Book club", start: at(-6, 19), end: at(-6, 21) },
];
export const WORK_EVENTS: CalendarEventApiData[] = [
{ summary: "Stand-up", start: at(0, 9), end: at(0, 9, 15) },
{ summary: "Sprint planning", start: at(2, 10), end: at(2, 12) },
{ summary: "Team lunch", start: at(4, 12), end: at(4, 13) },
{ summary: "Conference", ...allDay(8, 2) },
{ summary: "Quarterly review", start: at(-2, 14), end: at(-2, 15, 30) },
];
const toTime = (value: CalendarEventApiData["start"]) =>
new Date(
typeof value === "string"
? value
: "dateTime" in value
? value.dateTime
: value.date
).getTime();
const inRange = (
events: CalendarEventApiData[],
start: string | null,
end: string | null
): CalendarEventApiData[] => {
const startTime = start ? new Date(start).getTime() : -Infinity;
const endTime = end ? new Date(end).getTime() : Infinity;
return events.filter(
(event) => toTime(event.end) > startTime && toTime(event.start) < endTime
);
};
/**
* Answers event requests of calendar entities with the events of
* `getEvents`. A calendar without events gets `null`, like a calendar whose
* integration fails to return them.
*/
export const mockCalendarEvents = (
hass: MockHomeAssistant,
getEvents: (
entityId: string,
currentHass: MockHomeAssistant
) => CalendarEventApiData[] | null | undefined
) => {
hass.mockWS("calendar/event/subscribe", (msg, currentHass, onChange) => {
const events = getEvents(msg.entity_id, currentHass);
onChange?.({ events: events ? inRange(events, msg.start, msg.end) : null });
return () => undefined;
});
// Home Assistant before 2026.5 has no event subscription, so cards that
// support it ask the REST API instead.
hass.mockAPI(/^calendars\/[^/?]+\?/, (currentHass, _method, path) => {
const url = new URL(path, location.origin);
const entityId = url.pathname.slice(url.pathname.lastIndexOf("/") + 1);
const events = getEvents(entityId, currentHass);
if (!events) {
return Promise.reject(new Error("Calendar not found"));
}
return inRange(
events,
url.searchParams.get("start"),
url.searchParams.get("end")
);
});
};
+20 -20
View File
@@ -11,7 +11,7 @@ export const energyEntities = () =>
entity_id: "sensor.solar_production",
state: "88.6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Solar",
unit_of_measurement: "kWh",
},
@@ -20,7 +20,7 @@ export const energyEntities = () =>
entity_id: "sensor.battery_input",
state: "4",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Battery Input",
unit_of_measurement: "kWh",
},
@@ -29,7 +29,7 @@ export const energyEntities = () =>
entity_id: "sensor.battery_output",
state: "3",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Battery Output",
unit_of_measurement: "kWh",
},
@@ -38,7 +38,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_consumption_tarif_1",
state: "88.6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Grid consumption low tariff",
unit_of_measurement: "kWh",
},
@@ -47,7 +47,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_consumption_tarif_2",
state: "88.6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Grid consumption high tariff",
unit_of_measurement: "kWh",
},
@@ -56,7 +56,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_production_tarif_1",
state: "88.6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Returned to grid low tariff",
unit_of_measurement: "kWh",
},
@@ -65,7 +65,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_production_tarif_2",
state: "88.6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Returned to grid high tariff",
unit_of_measurement: "kWh",
},
@@ -74,7 +74,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_consumption_tarif_1_cost",
state: "2",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
unit_of_measurement: "EUR",
},
},
@@ -82,7 +82,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_consumption_tarif_2_cost",
state: "2",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
unit_of_measurement: "EUR",
},
},
@@ -90,7 +90,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_production_tarif_1_compensation",
state: "2",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
unit_of_measurement: "EUR",
},
},
@@ -98,7 +98,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_production_tarif_2_compensation",
state: "2",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
unit_of_measurement: "EUR",
},
},
@@ -138,7 +138,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_gas_cost",
state: "2",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
unit_of_measurement: "EUR",
},
},
@@ -146,7 +146,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_gas",
state: "4",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Gas",
unit_of_measurement: "m³",
},
@@ -155,7 +155,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_water",
state: "4000",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Water",
unit_of_measurement: "L",
},
@@ -164,7 +164,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_car",
state: "4",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Electric car",
unit_of_measurement: "kWh",
},
@@ -173,7 +173,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_ac",
state: "3",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Air conditioning",
unit_of_measurement: "kWh",
},
@@ -182,7 +182,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_washing_machine",
state: "6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Washing machine",
unit_of_measurement: "kWh",
},
@@ -191,7 +191,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_dryer",
state: "5.5",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Dryer",
unit_of_measurement: "kWh",
},
@@ -200,7 +200,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_heat_pump",
state: "6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Heat pump",
unit_of_measurement: "kWh",
},
@@ -209,7 +209,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_boiler",
state: "7",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Boiler",
unit_of_measurement: "kWh",
},
+2 -14
View File
@@ -8,7 +8,7 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
let entries: EntityRegistryEntry[] = [];
export const newEntry = (
const newEntry = (
entry: Pick<EntityRegistryEntry, "entity_id" | "platform" | "unique_id">
): EntityRegistryEntry => {
const now = Date.now() / 1000;
@@ -43,9 +43,7 @@ const displayFields = (entry: EntityRegistryEntry) => ({
hidden: entry.hidden_by !== null,
});
export const extEntry = (
entry: EntityRegistryEntry
): ExtEntityRegistryEntry => ({
const extEntry = (entry: EntityRegistryEntry): ExtEntityRegistryEntry => ({
...entry,
capabilities: {},
aliases: [],
@@ -58,16 +56,6 @@ export const mockEntityRegistry = (
entries = [...data];
// A new array, so subscribers see the registry changed after an update
hass.mockWS("config/entity_registry/list", () => [...entries]);
hass.mockWS(
"config/entity_registry/get",
(msg: { entity_id: string }): ExtEntityRegistryEntry => {
const entry = entries.find((e) => e.entity_id === msg.entity_id);
if (!entry) {
throw new Error(`Entity not found: ${msg.entity_id}`);
}
return extEntry(entry);
}
);
hass.mockWS(
"config/entity_registry/get_entries",
(msg: { entity_ids: string[] }) => {
-15
View File
@@ -1,15 +0,0 @@
import type { LogbookStreamMessage } from "../../../src/data/logbook";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
/**
* Answers logbook subscriptions with an empty logbook. No entries come from
* an automation or script trace.
*/
export const mockLogbook = (hass: MockHomeAssistant) => {
hass.mockWS("logbook/event_stream", (_msg, _currentHass, onChange) => {
onChange?.({ events: [] } satisfies LogbookStreamMessage);
return () => undefined;
});
hass.mockWS("trace/contexts", () => ({}));
};
+1
View File
@@ -149,6 +149,7 @@ const repositories: RepositoryBase[] = [
const repositoryInfo = (repository: RepositoryBase): RepositoryInfo => ({
...repository,
additional_info: `# ${repository.name}\n\n${repository.description}\n\nThis is a demo. Installing is not available.`,
beta: false,
default_branch: "main",
issues: 12,
releases: [repository.available_version],
+1 -3
View File
@@ -372,9 +372,7 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
const entityState = hass.states[id];
const state = entityState ? Number(entityState.state) : 1;
statistics[id] =
entityState &&
(entityState.attributes.state_class === "total" ||
entityState.attributes.state_class === "total_increasing")
entityState && "last_reset" in entityState.attributes
? generateSumStatistics(
start,
end,
-132
View File
@@ -1,132 +0,0 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../../../src/common/array/ensure-array";
import type { ExtractFromTargetResult } from "../../../src/data/target";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { getLabelIds } from "./label_registry";
// Expands a target to its areas, devices and entities, like core does
export const expandTarget = (
hass: MockHomeAssistant,
target: HassServiceTarget,
primaryEntitiesOnly = true
) => {
const missing = {
floors: [] as string[],
areas: [] as string[],
devices: [] as string[],
labels: [] as string[],
};
const knownLabelIds = getLabelIds();
const floorIds = ensureArray(target.floor_id ?? []);
const labelIds = ensureArray(target.label_id ?? []);
missing.floors.push(...floorIds.filter((id) => !(id in hass.floors)));
// Like core, unknown labels are reported, but still expanded
missing.labels.push(...labelIds.filter((id) => !knownLabelIds.includes(id)));
const hasLabel = (labels?: string[]) =>
labelIds.some((labelId) => labels?.includes(labelId));
// Unknown areas and devices are reported, but still referenced
const areaIds = new Set(ensureArray(target.area_id ?? []));
missing.areas.push(...[...areaIds].filter((id) => !(id in hass.areas)));
for (const area of Object.values(hass.areas)) {
if (
(area.floor_id && floorIds.includes(area.floor_id)) ||
hasLabel(area.labels)
) {
areaIds.add(area.area_id);
}
}
// A targeted or labeled device includes its child devices
const deviceIds = new Set<string>();
const addDevice = (deviceId: string) => {
deviceIds.add(deviceId);
for (const device of Object.values(hass.devices)) {
if (device.parent_device_id === deviceId) {
deviceIds.add(device.id);
}
}
};
for (const deviceId of ensureArray(target.device_id ?? [])) {
if (!(deviceId in hass.devices)) {
missing.devices.push(deviceId);
}
addDevice(deviceId);
}
for (const device of Object.values(hass.devices)) {
if (hasLabel(device.labels)) {
addDevice(device.id);
}
}
// A child device without its own area is in the area of its parent device
const deviceArea = (deviceId: string) => {
const device = hass.devices[deviceId];
return (
device?.area_id ??
(device?.parent_device_id
? hass.devices[device.parent_device_id]?.area_id
: undefined)
);
};
const areaDeviceIds = new Set<string>();
for (const device of Object.values(hass.devices)) {
const areaId = deviceArea(device.id);
if (areaId && areaIds.has(areaId)) {
areaDeviceIds.add(device.id);
}
}
const entityIds = new Set(ensureArray(target.entity_id ?? []));
for (const entity of Object.values(hass.entities)) {
// The display registry keeps the entities of a previous demo, and like
// core, hidden entities are never included indirectly
if (!(entity.entity_id in hass.states) || entity.hidden) {
continue;
}
const primary = !primaryEntitiesOnly || !entity.entity_category;
if (
hasLabel(entity.labels) ||
(primary &&
((entity.device_id && deviceIds.has(entity.device_id)) ||
(entity.area_id && areaIds.has(entity.area_id)) ||
// An entity with its own area is not in the area of its device
(!entity.area_id &&
entity.device_id &&
areaDeviceIds.has(entity.device_id))))
) {
entityIds.add(entity.entity_id);
}
}
return {
areas: [...areaIds],
devices: [...new Set([...deviceIds, ...areaDeviceIds])],
entities: [...entityIds],
missing,
};
};
export const mockExtractFromTarget = (hass: MockHomeAssistant) =>
hass.mockWS(
"extract_from_target",
(
msg: { target: HassServiceTarget; primary_entities_only?: boolean },
currentHass: MockHomeAssistant
): ExtractFromTargetResult => {
const { areas, devices, entities, missing } = expandTarget(
currentHass,
msg.target,
msg.primary_entities_only ?? true
);
return {
missing_areas: missing.areas,
missing_devices: missing.devices,
missing_floors: missing.floors,
missing_labels: missing.labels,
referenced_areas: areas,
referenced_devices: devices,
referenced_entities: entities,
};
}
);
-133
View File
@@ -1,133 +0,0 @@
import type {
ForecastAttribute,
ModernForecastType,
} from "../../../src/data/weather";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const HOUR = 3600000;
const CONDITIONS = [
"partlycloudy",
"rainy",
"cloudy",
"sunny",
"pouring",
"partlycloudy",
"sunny",
];
// Daily changes around the current temperature, so the forecast looks real.
const TEMPERATURE_CHANGES = [0, -1.5, -3, -1, 1.5, 2.5, 1];
const PRECIPITATION = new Map([
["rainy", 2.4],
["pouring", 9.1],
]);
const forecastEntry = (
datetime: Date,
temperature: number,
condition: string
): ForecastAttribute => ({
datetime: datetime.toISOString(),
condition,
temperature: Math.round(temperature * 10) / 10,
precipitation: PRECIPITATION.get(condition) ?? 0,
precipitation_probability: PRECIPITATION.has(condition) ? 80 : 10,
});
const generateForecast = (
forecastType: ModernForecastType,
temperature: number,
condition: string
): ForecastAttribute[] => {
const conditions = [condition, ...CONDITIONS.slice(1)];
const now = new Date();
if (forecastType === "hourly") {
now.setMinutes(0, 0, 0);
return Array.from({ length: 24 }, (_, hour) => {
const datetime = new Date(now.getTime() + (hour + 1) * HOUR);
// Warmest mid-afternoon and coldest before dawn
const change =
3 * Math.sin(((datetime.getHours() - 9) / 24) * 2 * Math.PI) -
3 * Math.sin(((now.getHours() - 9) / 24) * 2 * Math.PI);
return forecastEntry(
datetime,
temperature + change,
conditions[Math.floor(hour / 6)]
);
});
}
now.setHours(12, 0, 0, 0);
if (forecastType === "twice_daily") {
return conditions.flatMap((dayCondition, day) => {
const datetime = new Date(now.getTime() + day * 24 * HOUR);
const dayTemperature = temperature + TEMPERATURE_CHANGES[day];
return [
{
...forecastEntry(datetime, dayTemperature, dayCondition),
is_daytime: true,
},
{
...forecastEntry(
new Date(datetime.getTime() + 12 * HOUR),
dayTemperature - 6,
dayCondition
),
is_daytime: false,
},
];
});
}
return conditions.map((dayCondition, day) => {
const dayTemperature = temperature + TEMPERATURE_CHANGES[day];
return {
...forecastEntry(
new Date(now.getTime() + day * 24 * HOUR),
dayTemperature,
dayCondition
),
templow: Math.round((dayTemperature - 6) * 10) / 10,
};
});
};
/**
* Answers forecast subscriptions of weather entities. An entity with a
* `forecast` attribute gets that forecast. Other entities get a forecast made
* up from their condition and temperature.
*/
export const mockWeather = (hass: MockHomeAssistant) => {
hass.mockWS(
"weather/subscribe_forecast",
(
msg: { entity_id: string; forecast_type: ModernForecastType },
currentHass,
onChange
) => {
const stateObj = currentHass.states[msg.entity_id];
onChange?.({
type: msg.forecast_type,
forecast: stateObj
? (stateObj.attributes.forecast ??
generateForecast(
msg.forecast_type,
Number(stateObj.attributes.temperature ?? 15),
stateObj.state
))
: null,
});
return () => undefined;
}
);
};
+77 -10
View File
@@ -1,14 +1,68 @@
import {
FAMILY_EVENTS,
mockCalendarEvents,
WORK_EVENTS,
} from "../../../demo/src/stubs/calendar";
import { mockEntityRegistry } from "../../../demo/src/stubs/entity_registry";
import type { CalendarEventApiData } from "../../../src/data/calendar";
import { CalendarEntityFeature } from "../../../src/data/calendar";
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
// Sample calendars with events relative to today, so demos always show
// something in the current week and month.
const pad = (value: number) => String(value).padStart(2, "0");
const day = (offset: number): Date => {
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + offset);
return date;
};
const isoDate = (date: Date) =>
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const at = (offset: number, hours: number, minutes = 0) => {
const date = day(offset);
date.setHours(hours, minutes);
return date.toISOString();
};
const allDay = (offset: number, days = 1) => ({
start: isoDate(day(offset)),
end: isoDate(day(offset + days)),
all_day: true,
});
const FAMILY_EVENTS: CalendarEventApiData[] = [
{ summary: "School run", start: at(0, 8), end: at(0, 8, 30) },
{
summary: "Dentist",
start: at(0, 15, 30),
end: at(0, 16, 15),
location: "Main Street 12",
},
{ summary: "Grandma's birthday", ...allDay(1) },
{ summary: "Football practice", start: at(2, 17), end: at(2, 18, 30) },
{
summary: "Dinner with friends",
start: at(3, 19),
end: at(3, 22),
status: "tentative",
},
{ summary: "Summer cabin", ...allDay(5, 3) },
{ summary: "Parent-teacher meeting", start: at(9, 18), end: at(9, 19) },
{ summary: "Football practice", start: at(9, 17), end: at(9, 18, 30) },
{ summary: "Swimming lessons", start: at(12, 10), end: at(12, 11) },
{ summary: "Car service", start: at(-3, 9), end: at(-3, 11) },
{ summary: "Book club", start: at(-6, 19), end: at(-6, 21) },
];
const WORK_EVENTS: CalendarEventApiData[] = [
{ summary: "Stand-up", start: at(0, 9), end: at(0, 9, 15) },
{ summary: "Sprint planning", start: at(2, 10), end: at(2, 12) },
{ summary: "Team lunch", start: at(4, 12), end: at(4, 13) },
{ summary: "Conference", ...allDay(8, 2) },
{ summary: "Quarterly review", start: at(-2, 14), end: at(-2, 15, 30) },
];
const EVENTS: Record<string, CalendarEventApiData[] | null> = {
"calendar.family": FAMILY_EVENTS,
"calendar.work": WORK_EVENTS,
@@ -25,8 +79,8 @@ export const CALENDAR_ENTITIES = [
friendly_name: "Family",
message: "School run",
all_day: false,
start_time: FAMILY_EVENTS[0].start,
end_time: FAMILY_EVENTS[0].end,
start_time: at(0, 8),
end_time: at(0, 8, 30),
location: "",
description: "",
supported_features:
@@ -42,8 +96,8 @@ export const CALENDAR_ENTITIES = [
friendly_name: "Work",
message: "Stand-up",
all_day: false,
start_time: WORK_EVENTS[0].start,
end_time: WORK_EVENTS[0].end,
start_time: at(0, 9),
end_time: at(0, 9, 15),
},
},
{
@@ -91,7 +145,20 @@ export const mockCalendars = (hass: MockHomeAssistant) => {
registryEntry("calendar.empty"),
registryEntry("calendar.holidays"),
]);
mockCalendarEvents(hass, (entityId) => EVENTS[entityId]);
hass.mockWS("calendar/event/subscribe", (msg, _hass, onChange) => {
const start = new Date(msg.start).getTime();
const end = new Date(msg.end).getTime();
const events = EVENTS[msg.entity_id];
onChange?.({
events:
events?.filter(
(event) =>
new Date(event.end as string).getTime() > start &&
new Date(event.start as string).getTime() < end
) ?? null,
});
return () => undefined;
});
// Changes made in the event dialogs are accepted but not stored
hass.mockWS("calendar/event/create", () => null);
hass.mockWS("calendar/event/update", () => null);
+11 -11
View File
@@ -54,17 +54,17 @@
"@codemirror/view": "6.43.15",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.9.0",
"@formatjs/intl-displaynames": "7.4.0",
"@formatjs/intl-durationformat": "0.12.0",
"@formatjs/intl-getcanonicallocales": "3.3.0",
"@formatjs/intl-listformat": "8.4.0",
"@formatjs/intl-locale": "5.4.1",
"@formatjs/intl-numberformat": "9.5.0",
"@formatjs/intl-pluralrules": "6.4.0",
"@formatjs/intl-relativetimeformat": "12.4.0",
"@formatjs/intl-datetimeformat": "7.8.2",
"@formatjs/intl-displaynames": "7.3.15",
"@formatjs/intl-durationformat": "0.11.1",
"@formatjs/intl-getcanonicallocales": "3.2.12",
"@formatjs/intl-listformat": "8.3.15",
"@formatjs/intl-locale": "5.3.12",
"@formatjs/intl-numberformat": "9.4.3",
"@formatjs/intl-pluralrules": "6.3.16",
"@formatjs/intl-relativetimeformat": "12.3.16",
"@home-assistant/webawesome": "3.14.0-ha.2",
"@lezer/common": "1.5.5",
"@lezer/common": "1.5.4",
"@lezer/highlight": "1.2.5",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
@@ -177,7 +177,7 @@
"browserslist-useragent-regexp": "4.1.4",
"core-js-compat": "3.51.0",
"del": "9.0.1",
"eslint": "10.14.0",
"eslint": "10.13.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
+4 -63
View File
@@ -1,8 +1,8 @@
diff --git a/modular/sortable.core.esm.js b/modular/sortable.core.esm.js
index cb433ff25353f4836d2ffc813356f945f42acc6b..0a86ad22d05390a5b9ef002905fcd5926852b956 100644
index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa526090a00 100644
--- a/modular/sortable.core.esm.js
+++ b/modular/sortable.core.esm.js
@@ -1777,11 +1777,16 @@ Sortable.prototype = /** @lends Sortable.prototype */{
@@ -1781,11 +1781,16 @@ Sortable.prototype = /** @lends Sortable.prototype */{
}
if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, !!target) !== false) {
capture();
@@ -24,7 +24,7 @@ index cb433ff25353f4836d2ffc813356f945f42acc6b..0a86ad22d05390a5b9ef002905fcd592
}
parentEl = el; // actualization
@@ -1798,7 +1803,12 @@ Sortable.prototype = /** @lends Sortable.prototype */{
@@ -1802,7 +1807,12 @@ Sortable.prototype = /** @lends Sortable.prototype */{
targetRect = getRect(target);
if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, false) !== false) {
capture();
@@ -38,16 +38,7 @@ index cb433ff25353f4836d2ffc813356f945f42acc6b..0a86ad22d05390a5b9ef002905fcd592
parentEl = el; // actualization
changed();
@@ -1818,7 +1828,7 @@ Sortable.prototype = /** @lends Sortable.prototype */{
pastFirstInvertThresh = false;
isCircumstantialInvert = !differentRowCol && options.invertSwap || differentLevel;
}
- direction = _getSwapDirection(evt, target, targetRect, vertical, differentRowCol ? 1 : options.swapThreshold, options.invertedSwapThreshold == null ? options.swapThreshold : options.invertedSwapThreshold, isCircumstantialInvert, lastTarget === target);
+ direction = _getSwapDirection(evt, target, targetRect, vertical, differentRowCol ? 1 : options.swapThreshold, options.invertedSwapThreshold == null ? options.swapThreshold : options.invertedSwapThreshold, isCircumstantialInvert, lastTarget === target, _isRtl(el, vertical));
var sibling;
if (direction !== 0) {
// Check if target is beside dragEl in respective direction (ignoring hidden elements)
@@ -1845,10 +1855,15 @@ Sortable.prototype = /** @lends Sortable.prototype */{
@@ -1849,10 +1859,15 @@ Sortable.prototype = /** @lends Sortable.prototype */{
_silent = true;
setTimeout(_unsilent, 30);
capture();
@@ -67,53 +58,3 @@ index cb433ff25353f4836d2ffc813356f945f42acc6b..0a86ad22d05390a5b9ef002905fcd592
}
// Undo chrome's scroll adjustment (has no effect on other browsers)
@@ -2242,19 +2257,48 @@ function _disableDraggable(el) {
function _unsilent() {
_silent = false;
}
+// Horizontal RTL lists run from right to left. Mirroring the x axis lets the
+// checks below keep assuming that the list runs from left to right.
+function _isRtl(el, vertical) {
+ return !vertical && css(el, 'direction') === 'rtl';
+}
+function _mirrorX(rect) {
+ return {
+ left: -rect.right,
+ right: -rect.left,
+ top: rect.top,
+ bottom: rect.bottom,
+ width: rect.width,
+ height: rect.height
+ };
+}
function _ghostIsFirst(evt, vertical, sortable) {
var firstElRect = getRect(getChild(sortable.el, 0, sortable.options, true));
var childContainingRect = getChildContainingRectFromElement(sortable.el, sortable.options, ghostEl);
+ if (_isRtl(sortable.el, vertical)) {
+ evt = { clientX: -evt.clientX, clientY: evt.clientY };
+ firstElRect = _mirrorX(firstElRect);
+ childContainingRect = _mirrorX(childContainingRect);
+ }
var spacer = 10;
return vertical ? evt.clientX < childContainingRect.left - spacer || evt.clientY < firstElRect.top && evt.clientX < firstElRect.right : evt.clientY < childContainingRect.top - spacer || evt.clientY < firstElRect.bottom && evt.clientX < firstElRect.left;
}
function _ghostIsLast(evt, vertical, sortable) {
var lastElRect = getRect(lastChild(sortable.el, sortable.options.draggable));
var childContainingRect = getChildContainingRectFromElement(sortable.el, sortable.options, ghostEl);
+ if (_isRtl(sortable.el, vertical)) {
+ evt = { clientX: -evt.clientX, clientY: evt.clientY };
+ lastElRect = _mirrorX(lastElRect);
+ childContainingRect = _mirrorX(childContainingRect);
+ }
var spacer = 10;
return vertical ? evt.clientX > childContainingRect.right + spacer || evt.clientY > lastElRect.bottom && evt.clientX > lastElRect.left : evt.clientY > childContainingRect.bottom + spacer || evt.clientX > lastElRect.right && evt.clientY > lastElRect.top;
}
-function _getSwapDirection(evt, target, targetRect, vertical, swapThreshold, invertedSwapThreshold, invertSwap, isLastTarget) {
+function _getSwapDirection(evt, target, targetRect, vertical, swapThreshold, invertedSwapThreshold, invertSwap, isLastTarget, rtl) {
+ if (rtl) {
+ evt = { clientX: -evt.clientX, clientY: evt.clientY };
+ targetRect = _mirrorX(targetRect);
+ }
var mouseOnAxis = vertical ? evt.clientY : evt.clientX,
targetLength = vertical ? targetRect.height : targetRect.width,
targetS1 = vertical ? targetRect.top : targetRect.left,
+194 -194
View File
@@ -167,12 +167,12 @@ overrides:
lit-html: 3.3.3
clean-css: 5.3.3
'@lit/reactive-element': 2.1.2
tslib: 2.9.1
tslib: 2.8.1
patchedDependencies:
'@material/[email protected]': 9450e509a86887d8f68ca9cf3f81e6ed70e94e49de13c5f6f20f94519ad4a987
[email protected]: 4c014bd1952b4d1f21311ed2212dc87192e70b6e782597ecc7a485270247bbb5
[email protected]: f25f02dcc8720341c3cfac312e0502081328108270852ad039187c2c5e2ea432
[email protected]: f2ec2ae6877dfc201c542b4f9ded1d03831258bf328ae7bba876d14ce4508a06
[email protected]: cdc7ece96d53357d38ff3c124a2553f86e3a5dc333d641d223eb6f07e28275f0
[email protected]: 75e6346ad8d8af554c167f77ac7d9b9652f010398efdc6b399925a4795133099
@@ -223,38 +223,38 @@ importers:
specifier: 2.0.17
version: 2.0.17
'@formatjs/intl-datetimeformat':
specifier: 7.9.0
version: 7.9.0
specifier: 7.8.2
version: 7.8.2
'@formatjs/intl-displaynames':
specifier: 7.4.0
version: 7.4.0
specifier: 7.3.15
version: 7.3.15
'@formatjs/intl-durationformat':
specifier: 0.12.0
version: 0.12.0
specifier: 0.11.1
version: 0.11.1
'@formatjs/intl-getcanonicallocales':
specifier: 3.3.0
version: 3.3.0
specifier: 3.2.12
version: 3.2.12
'@formatjs/intl-listformat':
specifier: 8.4.0
version: 8.4.0
specifier: 8.3.15
version: 8.3.15
'@formatjs/intl-locale':
specifier: 5.4.1
version: 5.4.1
specifier: 5.3.12
version: 5.3.12
'@formatjs/intl-numberformat':
specifier: 9.5.0
version: 9.5.0
specifier: 9.4.3
version: 9.4.3
'@formatjs/intl-pluralrules':
specifier: 6.4.0
version: 6.4.0
specifier: 6.3.16
version: 6.3.16
'@formatjs/intl-relativetimeformat':
specifier: 12.4.0
version: 12.4.0
specifier: 12.3.16
version: 12.3.16
'@home-assistant/webawesome':
specifier: 3.14.0-ha.2
version: 3.14.0-ha.2(@floating-ui/[email protected])(@types/[email protected])
'@lezer/common':
specifier: 1.5.5
version: 1.5.5
specifier: 1.5.4
version: 1.5.4
'@lezer/highlight':
specifier: 1.2.5
version: 1.2.5
@@ -422,7 +422,7 @@ importers:
version: 2.8.1
sortablejs:
specifier: 1.15.7
version: 1.15.7(patch_hash=f25f02dcc8720341c3cfac312e0502081328108270852ad039187c2c5e2ea432)
version: 1.15.7(patch_hash=f2ec2ae6877dfc201c542b4f9ded1d03831258bf328ae7bba876d14ce4508a06)
stacktrace-js:
specifier: 2.0.2
version: 2.0.2
@@ -492,13 +492,13 @@ importers:
version: 4.22.3([email protected])
'@eslint/js':
specifier: 10.0.1
version: 10.0.1([email protected]4.0([email protected]))
version: 10.0.1([email protected]3.0([email protected]))
'@gfx/zopfli':
specifier: 1.0.15
version: 1.0.15
'@html-eslint/eslint-plugin':
specifier: 0.67.0
version: 0.67.0([email protected]4.0([email protected]))
version: 0.67.0([email protected]3.0([email protected]))
'@lokalise/node-api':
specifier: 17.0.1
version: 17.0.1
@@ -587,29 +587,29 @@ importers:
specifier: 9.0.1
version: 9.0.1
eslint:
specifier: 10.14.0
version: 10.14.0([email protected])
specifier: 10.13.0
version: 10.13.0([email protected])
eslint-config-prettier:
specifier: 10.1.8
version: 10.1.8([email protected]4.0([email protected]))
version: 10.1.8([email protected]3.0([email protected]))
eslint-import-resolver-webpack:
specifier: 0.13.11
version: 0.13.11([email protected])
eslint-plugin-import-x:
specifier: 4.17.1
version: 4.17.1(@typescript-eslint/[email protected]([email protected]4.0([email protected]))([email protected])([email protected]))([email protected]4.0([email protected]))([email protected])
version: 4.17.1(@typescript-eslint/[email protected]([email protected]3.0([email protected]))([email protected])([email protected]))([email protected]3.0([email protected]))([email protected])
eslint-plugin-lit:
specifier: 2.3.1
version: 2.3.1([email protected]4.0([email protected]))
version: 2.3.1([email protected]3.0([email protected]))
eslint-plugin-lit-a11y:
specifier: 5.1.3
version: 5.1.3([email protected]4.0([email protected]))
version: 5.1.3([email protected]3.0([email protected]))
eslint-plugin-unused-imports:
specifier: 4.4.1
version: 4.4.1(@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]4.0([email protected]))([email protected])([email protected]))([email protected]4.0([email protected]))([email protected])([email protected]))([email protected]4.0([email protected]))
version: 4.4.1(@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]3.0([email protected]))([email protected])([email protected]))([email protected]3.0([email protected]))([email protected])([email protected]))([email protected]3.0([email protected]))
eslint-plugin-wc:
specifier: 3.1.0
version: 3.1.0([email protected]4.0([email protected]))
version: 3.1.0([email protected]3.0([email protected]))
fancy-log:
specifier: 2.0.0
version: 2.0.0
@@ -699,7 +699,7 @@ importers:
version: 6.0.3
typescript-eslint:
specifier: 8.72.1
version: 8.72.1([email protected]4.0([email protected]))([email protected])([email protected])
version: 8.72.1([email protected]3.0([email protected]))([email protected])([email protected])
vite:
specifier: 8.3.5
version: 8.3.5(@types/[email protected])([email protected])([email protected])
@@ -1953,41 +1953,41 @@ packages:
'@formatjs/[email protected]':
resolution: {integrity: sha512-ca7tZMLpfHX3sUD7hP6gE/qr6Iq89EDd3ZLeRhbsB7r0UD55DGJq+tmFLzrGrnx5StahUevQmQQVtbDRHsfDHA==}
'@formatjs/intl-datetimeformat@7.9.0':
resolution: {integrity: sha512-C17ilxM6zJO1wAYDwoW4E0Oy8K6krEC7MjcZAArW/wEthBb3Lab0LBTh0G7liLOGEj74iCTWm2TYHtLv+SbtYg==}
'@formatjs/intl-datetimeformat@7.8.2':
resolution: {integrity: sha512-5iCy2mXr6cpEd4N87HLVbN2zRz71dRLMmI8hMY9IgAVaob5fsVocEah6T4w3pPUVYz/aDO2TFGXErPY3JAEUsQ==}
'@formatjs/intl-displaynames@7.4.0':
resolution: {integrity: sha512-D2SZXvkeMadjGeg1vlqcqtNg0hJIKxUL5HiFkSdCt8pmKH5sNMP0DKND+X1cfuOV0EMyKIp04WYRSIHFFVYAfA==}
'@formatjs/intl-displaynames@7.3.15':
resolution: {integrity: sha512-U26iRwxxnD4aZY+aDiSEV5Ym/7Db5S9OxKQ4c3T+WFFIj+DPKEQLh+tC++dMH1SuEa8CSWvPJkpaADa+WHn3qA==}
'@formatjs/[email protected]2.0':
resolution: {integrity: sha512-5TJSDf5ti5+Zdo/rE6tgyl06Wv4MJKGZqvxxmb3i3uIVtRXYzDgEaiZOCcOjh+UoxIMBHqBn1GhKooep8Wq0KQ==}
'@formatjs/[email protected]1.1':
resolution: {integrity: sha512-Mkuc+Q8/96Hb0w4Pwa9sqkKVQVl75TCaqaXIXHgsVsLDko+FKQhw9n/7BnbonueqPxkQkGYvn8/G2IKzT970og==}
'@formatjs/intl-getcanonicallocales@3.3.0':
resolution: {integrity: sha512-ndj9jw/LHAftSe34D/A6+lj3Y7GHVfPEZ6FZP2wjd8/beYeh3d1sjlSpZSBlzegSKS3U3mUEIzLCpI0+jKNT6w==}
'@formatjs/intl-getcanonicallocales@3.2.12':
resolution: {integrity: sha512-THqQLTJUH2z2A7oE1guW8VzpGpsGFxwWZ+l8cI8Bk75xUU8DxtJoLfKl+5a6fYK26GeDWDWE0UbDhE5SwcLoFg==}
'@formatjs/intl-listformat@8.4.0':
resolution: {integrity: sha512-swZL3hgHN22Ga57H+GDT/PgQnF+eeAIj8irudwMupOJR4df5b1M67GwkrAOSJc+K6CdKxdArajrMlOcTLfg2wQ==}
'@formatjs/intl-listformat@8.3.15':
resolution: {integrity: sha512-dZEUYngBoAWLgYXvoBX3P6jIk5cg2rva7p5ZJTTn4lsCx/9abUKoPI2Wf59twxl5jgw1SDQMR1MBzJ7oiHwffw==}
'@formatjs/intl-locale@5.4.1':
resolution: {integrity: sha512-whv9X0efjqLE6VJGXVr5+mwFfCTiPYDq6/jw5aZEwBJIlTyHJjKLGzXEzyjwmarzYCzLMFhepKYf/XeTR/Z8qA==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-OvfBelurObm02D1+4BsuHSt3Xkn49RZPo7uJXt/1Z0dNET3EN39kY1rKMV3kG0tn0p4gyGWrcFx9HyeF07cq5g==}
'@formatjs/intl-locale@5.3.12':
resolution: {integrity: sha512-bI5CrvuC6A5PMOIiHX+6ETTHXzUofHw1U/fkI59sQJ+BFbkglxIVVncFPxHf4IHmAqAs/Rlp2bdzTEcLaq2tQw==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==}
'@formatjs/intl-[email protected].0':
resolution: {integrity: sha512-l2kTmIyUEw/peqc01A1Fz3WxJHsb9ntAUIgAe0016svJJa6TBxriGARfTzOOI5WvsDO1dWXHHQ7gkOv4gtHm2w==}
'@formatjs/intl-[email protected].0':
resolution: {integrity: sha512-Z7TD3ssNry1Av1dVDoq/mzpxGyxqWwU0ClWEo+BB3k0JQTeQvUGmeK9WZGYvlqStS0UqkaKXk3dfjsb6NaHAxw==}
'@formatjs/intl-pluralrules@6.4.0':
resolution: {integrity: sha512-dPsgM2WeLIRPzH8wqv9mnq2sdnAeyz7n7gqv/JSK6jFVvJEEZIyZX0XalFZsuuNQ8U2CQQ5RxCy0h9U/nirJIQ==}
'@formatjs/intl-numberformat@9.4.3':
resolution: {integrity: sha512-absVu2I7FlWl+Buictw2PL8Fl385uab3bEA3TCCTu+0DBHHFE+RTbmPmj0nUVO82U1b2Qw3/OA2I7KKLI48EpQ==}
'@formatjs/intl-[email protected]':
resolution: {integrity: sha512-sXYW00Zh2rodj2HguxY/IoXSsrru3YAIbvH+JeXPvpQs5ukS2eWwv3zvcPTWZyFzqSiwFiKAuPtdr/6kQn3u6g==}
'@formatjs/intl-[email protected]':
resolution: {integrity: sha512-iOqCpBM2fo7bbbWqno+yN9WioepV01N2q3KawFZhRYADzijpzmIIA6cbFXrriCI1Mt2q5c7mceiKHoQkEFf0/Q==}
'@formatjs/intl-[email protected]':
resolution: {integrity: sha512-0nL6a4aMrBVwFZX7udcuxxUtxldY/Q4KoGuQVNx4/roQqnZmOwDvzRb4DkEvtJZ3b76mk+V7zfJmm3nJycOFHQ==}
'@formatjs/intl-[email protected]':
resolution: {integrity: sha512-wIFsJiH/p9yMLzY3jyvht2vcjgJopMn30ePQQh7upkB5YYSAX3whtiqytG1EPhy2QKDy1PKOk+BJVNLA6HLePg==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-oRzzAbJA08psr6AO0jgXAdx4HZ2vGNeja1fgaio2R2ne8DLtGPV+fa3gxI/t8J2dNPOndrwVAZVVfbH6GDFDwA==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-tDkXnA4qpIFcDWac8CyVJq6oW8DR7W44QDUBsfXWIIJD/FYYen0QoH46W7XsVMFfPOVKkvbufjboZrrWbEfmww==}
@@ -2162,8 +2162,8 @@ packages:
'@konnorr/[email protected]':
resolution: {integrity: sha512-6Ae3AfG45MOqF9Qde+1YxIxKf/GUsRmcriV7iCb/HaR+Mk1YDFYP3RINDRMI8z8YMWW3BnSthOujX9RoOex/Kw==}
'@lezer/[email protected].5':
resolution: {integrity: sha512-Pw7PVCndruF1pb3ebCxpSVpkMHSDjwRCLcTRw+RizUKco5QbWylwq2g4lnG/RYN7yTTQDXE9F77MwtkAyphrjg==}
'@lezer/[email protected].4':
resolution: {integrity: sha512-xt3Q4z64xM+xhg4M7NXqw1N//1O74bUFo5kz6fwMeC4qwxSuRdwkYSmjcqCEEKYkNw5e67f2hcNqBoNERAEU0w==}
'@lezer/[email protected]':
resolution: {integrity: sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g==}
@@ -4803,8 +4803,8 @@ packages:
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
[email protected]4.0:
resolution: {integrity: sha512-T0pNsxkb9IKmtQjhpK3d7nWOpFv+YHuMbTJnSVMoQblF0onvXfErrtp4V1jHtsQPan7dKaGVD0q1WCXnzMF31A==}
[email protected]3.0:
resolution: {integrity: sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
hasBin: true
peerDependencies:
@@ -6452,8 +6452,8 @@ packages:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
[email protected].7:
resolution: {integrity: sha512-wWBro/7hJ40ye6TPiXkUQoXKCw23xSsjriUvqd7Uls9Sso3oLiON1flc18cCdnjgCRXw4fBVqUJJeOdDTh3P3g==}
[email protected].5:
resolution: {integrity: sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==}
engines: {node: '>=4'}
[email protected]:
@@ -7258,8 +7258,8 @@ packages:
typescript:
optional: true
tslib@2.9.1:
resolution: {integrity: sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
[email protected]:
resolution: {integrity: sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==}
@@ -9138,7 +9138,7 @@ snapshots:
'@bundle-stats/[email protected]([email protected])':
dependencies:
core-js: 3.51.0
tslib: 2.9.1
tslib: 2.8.1
'@cacheable/[email protected]':
dependencies:
@@ -9157,21 +9157,21 @@ snapshots:
'@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.15
'@lezer/common': 1.5.5
'@lezer/common': 1.5.4
'@codemirror/[email protected]':
dependencies:
'@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.15
'@lezer/common': 1.5.5
'@lezer/common': 1.5.4
'@codemirror/[email protected]':
dependencies:
'@codemirror/autocomplete': 6.20.4
'@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.5
'@lezer/common': 1.5.4
'@lezer/css': 1.3.6
'@codemirror/[email protected]':
@@ -9182,7 +9182,7 @@ snapshots:
'@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.15
'@lezer/common': 1.5.5
'@lezer/common': 1.5.4
'@lezer/css': 1.3.6
'@lezer/html': 1.3.13
@@ -9193,7 +9193,7 @@ snapshots:
'@codemirror/lint': 6.9.7
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.15
'@lezer/common': 1.5.5
'@lezer/common': 1.5.4
'@lezer/javascript': 1.5.4
'@codemirror/[email protected]':
@@ -9203,7 +9203,7 @@ snapshots:
'@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.15
'@lezer/common': 1.5.5
'@lezer/common': 1.5.4
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
@@ -9212,7 +9212,7 @@ snapshots:
'@codemirror/autocomplete': 6.20.4
'@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.5
'@lezer/common': 1.5.4
'@lezer/python': 1.1.19
'@codemirror/[email protected]':
@@ -9220,7 +9220,7 @@ snapshots:
'@codemirror/autocomplete': 6.20.4
'@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.5
'@lezer/common': 1.5.4
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/yaml': 1.0.4
@@ -9230,7 +9230,7 @@ snapshots:
'@codemirror/state': 6.7.6
'@codemirror/streamparser': 6.0.0
'@codemirror/view': 6.43.15
'@lezer/common': 1.5.5
'@lezer/common': 1.5.4
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
style-mod: 4.1.3
@@ -9299,38 +9299,38 @@ snapshots:
'@emnapi/[email protected]':
dependencies:
'@emnapi/wasi-threads': 1.2.1
tslib: 2.9.1
tslib: 2.8.1
optional: true
'@emnapi/[email protected]':
dependencies:
'@emnapi/wasi-threads': 1.2.3
tslib: 2.9.1
tslib: 2.8.1
optional: true
'@emnapi/[email protected]':
dependencies:
tslib: 2.9.1
tslib: 2.8.1
optional: true
'@emnapi/[email protected]':
dependencies:
tslib: 2.9.1
tslib: 2.8.1
optional: true
'@emnapi/[email protected]':
dependencies:
tslib: 2.9.1
tslib: 2.8.1
optional: true
'@emnapi/[email protected]':
dependencies:
tslib: 2.9.1
tslib: 2.8.1
optional: true
'@eslint-community/[email protected]([email protected]4.0([email protected]))':
'@eslint-community/[email protected]([email protected]3.0([email protected]))':
dependencies:
eslint: 10.14.0([email protected])
eslint: 10.13.0([email protected])
eslint-visitor-keys: 3.4.3
'@eslint-community/[email protected]': {}
@@ -9355,9 +9355,9 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
'@eslint/[email protected]([email protected]4.0([email protected]))':
'@eslint/[email protected]([email protected]3.0([email protected]))':
optionalDependencies:
eslint: 10.14.0([email protected])
eslint: 10.13.0([email protected])
'@eslint/[email protected]': {}
@@ -9391,11 +9391,11 @@ snapshots:
'@formatjs/fast-memoize': 2.2.7
'@formatjs/intl-localematcher': 0.6.1
decimal.js: 10.6.0
tslib: 2.9.1
tslib: 2.8.1
'@formatjs/[email protected]':
dependencies:
tslib: 2.9.1
tslib: 2.8.1
'@formatjs/[email protected]': {}
@@ -9403,7 +9403,7 @@ snapshots:
dependencies:
'@formatjs/ecma402-abstract': 2.3.4
'@formatjs/icu-skeleton-parser': 1.8.14
tslib: 2.9.1
tslib: 2.8.1
'@formatjs/[email protected]':
dependencies:
@@ -9412,58 +9412,58 @@ snapshots:
'@formatjs/[email protected]':
dependencies:
'@formatjs/ecma402-abstract': 2.3.4
tslib: 2.9.1
tslib: 2.8.1
'@formatjs/[email protected]': {}
'@formatjs/intl-datetimeformat@7.9.0':
'@formatjs/intl-datetimeformat@7.8.2':
dependencies:
'@formatjs/bigdecimal': 0.2.7
'@formatjs/intl-localematcher': 0.10.0
'@formatjs/intl-localematcher': 0.9.0
'@formatjs/intl-displaynames@7.4.0':
'@formatjs/intl-displaynames@7.3.15':
dependencies:
'@formatjs/intl-localematcher': 0.10.0
'@formatjs/intl-localematcher': 0.9.0
'@formatjs/[email protected]2.0':
'@formatjs/[email protected]1.1':
dependencies:
'@formatjs/bigdecimal': 0.2.7
'@formatjs/intl-localematcher': 0.10.0
'@formatjs/intl-localematcher': 0.9.0
'@formatjs/intl-getcanonicallocales@3.3.0': {}
'@formatjs/intl-getcanonicallocales@3.2.12': {}
'@formatjs/intl-listformat@8.4.0':
'@formatjs/intl-listformat@8.3.15':
dependencies:
'@formatjs/intl-localematcher': 0.10.0
'@formatjs/intl-localematcher': 0.9.0
'@formatjs/intl-locale@5.4.1':
'@formatjs/intl-locale@5.3.12':
dependencies:
'@formatjs/intl-getcanonicallocales': 3.3.0
'@formatjs/intl-supportedvaluesof': 2.5.0
'@formatjs/[email protected]':
dependencies:
'@formatjs/fast-memoize': 3.1.7
'@formatjs/intl-getcanonicallocales': 3.2.12
'@formatjs/intl-supportedvaluesof': 2.3.11
'@formatjs/[email protected]':
dependencies:
tslib: 2.9.1
tslib: 2.8.1
'@formatjs/intl-[email protected].0':
'@formatjs/intl-[email protected].0':
dependencies:
'@formatjs/fast-memoize': 3.1.7
'@formatjs/[email protected]':
dependencies:
'@formatjs/bigdecimal': 0.2.7
'@formatjs/intl-localematcher': 0.10.0
'@formatjs/intl-localematcher': 0.9.0
'@formatjs/intl-pluralrules@6.4.0':
'@formatjs/intl-pluralrules@6.3.16':
dependencies:
'@formatjs/bigdecimal': 0.2.7
'@formatjs/intl-localematcher': 0.10.0
'@formatjs/intl-localematcher': 0.9.0
'@formatjs/intl-relativetimeformat@12.4.0':
'@formatjs/intl-relativetimeformat@12.3.16':
dependencies:
'@formatjs/intl-localematcher': 0.10.0
'@formatjs/intl-localematcher': 0.9.0
'@formatjs/intl-supportedvaluesof@2.5.0':
'@formatjs/intl-supportedvaluesof@2.3.11':
dependencies:
'@formatjs/fast-memoize': 3.1.7
@@ -9473,7 +9473,7 @@ snapshots:
'@formatjs/fast-memoize': 2.2.7
'@formatjs/icu-messageformat-parser': 2.11.2
intl-messageformat: 10.7.16
tslib: 2.9.1
tslib: 2.8.1
optionalDependencies:
typescript: 6.0.3
@@ -9523,7 +9523,7 @@ snapshots:
'@html-eslint/types': 0.67.0
html-standard: 0.0.13
'@html-eslint/[email protected]([email protected]4.0([email protected]))':
'@html-eslint/[email protected]([email protected]3.0([email protected]))':
dependencies:
'@eslint/plugin-kit': 0.4.1
'@html-eslint/core': 0.67.0
@@ -9532,7 +9532,7 @@ snapshots:
'@html-eslint/template-syntax-parser': 0.67.0
'@html-eslint/types': 0.67.0
'@rviscomi/capo.js': 2.2.1
eslint: 10.14.0([email protected])
eslint: 10.13.0([email protected])
html-standard: 0.0.13
'@html-eslint/[email protected]':
@@ -9690,43 +9690,43 @@ snapshots:
'@konnorr/[email protected]': {}
'@lezer/[email protected].5': {}
'@lezer/[email protected].4': {}
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.5
'@lezer/common': 1.5.4
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.5
'@lezer/common': 1.5.4
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.5
'@lezer/common': 1.5.4
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.5
'@lezer/common': 1.5.4
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.5
'@lezer/common': 1.5.4
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.5
'@lezer/common': 1.5.4
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.5
'@lezer/common': 1.5.4
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
@@ -9765,7 +9765,7 @@ snapshots:
'@lit-labs/[email protected]':
dependencies:
lit: 3.3.3
tslib: 2.9.1
tslib: 2.8.1
'@lit/[email protected]':
dependencies:
@@ -9830,24 +9830,24 @@ snapshots:
'@material/[email protected]':
dependencies:
tslib: 2.9.1
tslib: 2.8.1
'@material/[email protected]':
dependencies:
tslib: 2.9.1
tslib: 2.8.1
'@material/[email protected]':
dependencies:
tslib: 2.9.1
tslib: 2.8.1
'@material/[email protected]':
dependencies:
'@material/feature-targeting': 14.0.0-canary.53b3cad2f.0
tslib: 2.9.1
tslib: 2.8.1
'@material/[email protected]':
dependencies:
tslib: 2.9.1
tslib: 2.8.1
'@material/[email protected]':
dependencies:
@@ -9866,21 +9866,21 @@ snapshots:
'@material/shape': 14.0.0-canary.53b3cad2f.0
'@material/theme': 14.0.0-canary.53b3cad2f.0
'@material/typography': 14.0.0-canary.53b3cad2f.0
tslib: 2.9.1
tslib: 2.8.1
'@material/[email protected]':
dependencies:
'@material/base': 14.0.0-canary.53b3cad2f.0
'@material/dom': 14.0.0-canary.53b3cad2f.0
lit: 3.3.3
tslib: 2.9.1
tslib: 2.8.1
'@material/[email protected]':
dependencies:
'@material/mwc-base': 0.27.0
'@material/mwc-ripple': 0.27.0
lit: 3.3.3
tslib: 2.9.1
tslib: 2.8.1
'@material/[email protected](patch_hash=9450e509a86887d8f68ca9cf3f81e6ed70e94e49de13c5f6f20f94519ad4a987)':
dependencies:
@@ -9892,7 +9892,7 @@ snapshots:
'@material/mwc-radio': 0.27.0
'@material/mwc-ripple': 0.27.0
lit: 3.3.3
tslib: 2.9.1
tslib: 2.8.1
'@material/[email protected]':
dependencies:
@@ -9900,7 +9900,7 @@ snapshots:
'@material/mwc-ripple': 0.27.0
'@material/radio': 14.0.0-canary.53b3cad2f.0
lit: 3.3.3
tslib: 2.9.1
tslib: 2.8.1
'@material/[email protected]':
dependencies:
@@ -9908,7 +9908,7 @@ snapshots:
'@material/mwc-base': 0.27.0
'@material/ripple': 14.0.0-canary.53b3cad2f.0
lit: 3.3.3
tslib: 2.9.1
tslib: 2.8.1
'@material/[email protected]':
dependencies:
@@ -9921,7 +9921,7 @@ snapshots:
'@material/ripple': 14.0.0-canary.53b3cad2f.0
'@material/theme': 14.0.0-canary.53b3cad2f.0
'@material/touch-target': 14.0.0-canary.53b3cad2f.0
tslib: 2.9.1
tslib: 2.8.1
'@material/[email protected]':
dependencies:
@@ -9931,37 +9931,37 @@ snapshots:
'@material/feature-targeting': 14.0.0-canary.53b3cad2f.0
'@material/rtl': 14.0.0-canary.53b3cad2f.0
'@material/theme': 14.0.0-canary.53b3cad2f.0
tslib: 2.9.1
tslib: 2.8.1
'@material/[email protected]':
dependencies:
'@material/theme': 14.0.0-canary.53b3cad2f.0
tslib: 2.9.1
tslib: 2.8.1
'@material/[email protected]':
dependencies:
'@material/feature-targeting': 14.0.0-canary.53b3cad2f.0
'@material/rtl': 14.0.0-canary.53b3cad2f.0
'@material/theme': 14.0.0-canary.53b3cad2f.0
tslib: 2.9.1
tslib: 2.8.1
'@material/[email protected]':
dependencies:
'@material/feature-targeting': 14.0.0-canary.53b3cad2f.0
tslib: 2.9.1
tslib: 2.8.1
'@material/[email protected]':
dependencies:
'@material/base': 14.0.0-canary.53b3cad2f.0
'@material/feature-targeting': 14.0.0-canary.53b3cad2f.0
'@material/rtl': 14.0.0-canary.53b3cad2f.0
tslib: 2.9.1
tslib: 2.8.1
'@material/[email protected]':
dependencies:
'@material/feature-targeting': 14.0.0-canary.53b3cad2f.0
'@material/theme': 14.0.0-canary.53b3cad2f.0
tslib: 2.9.1
tslib: 2.8.1
'@mdi/[email protected]': {}
@@ -10218,7 +10218,7 @@ snapshots:
'@npmcli/[email protected]':
dependencies:
postcss-selector-parser: 7.1.7
postcss-selector-parser: 7.1.5
'@npmcli/[email protected]': {}
@@ -10846,7 +10846,7 @@ snapshots:
'@swc/[email protected]':
dependencies:
tslib: 2.9.1
tslib: 2.8.1
'@thepassle/[email protected]':
dependencies:
@@ -10958,7 +10958,7 @@ snapshots:
'@tybys/[email protected]':
dependencies:
tslib: 2.9.1
tslib: 2.8.1
optional: true
'@types/[email protected]': {}
@@ -11075,15 +11075,15 @@ snapshots:
dependencies:
'@types/node': 24.19.2
'@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]4.0([email protected]))([email protected])([email protected]))([email protected]4.0([email protected]))([email protected])([email protected])':
'@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]3.0([email protected]))([email protected])([email protected]))([email protected]3.0([email protected]))([email protected])([email protected])':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.72.1([email protected]4.0([email protected]))([email protected])([email protected])
'@typescript-eslint/parser': 8.72.1([email protected]3.0([email protected]))([email protected])([email protected])
'@typescript-eslint/scope-manager': 8.72.1
'@typescript-eslint/type-utils': 8.72.1([email protected]4.0([email protected]))([email protected])([email protected])
'@typescript-eslint/utils': 8.72.1([email protected]4.0([email protected]))([email protected])([email protected])
'@typescript-eslint/type-utils': 8.72.1([email protected]3.0([email protected]))([email protected])([email protected])
'@typescript-eslint/utils': 8.72.1([email protected]3.0([email protected]))([email protected])([email protected])
'@typescript-eslint/visitor-keys': 8.72.1
eslint: 10.14.0([email protected])
eslint: 10.13.0([email protected])
ignore: 7.0.6
natural-compare: 1.4.0
ts-api-utils: 2.5.0([email protected])
@@ -11091,14 +11091,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/[email protected]([email protected]4.0([email protected]))([email protected])([email protected])':
'@typescript-eslint/[email protected]([email protected]3.0([email protected]))([email protected])([email protected])':
dependencies:
'@typescript-eslint/scope-manager': 8.72.1
'@typescript-eslint/types': 8.72.1
'@typescript-eslint/typescript-estree': 8.72.1([email protected])([email protected])
'@typescript-eslint/visitor-keys': 8.72.1
debug: 4.4.3([email protected])
eslint: 10.14.0([email protected])
eslint: 10.13.0([email protected])
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@@ -11121,13 +11121,13 @@ snapshots:
dependencies:
typescript: 6.0.3
'@typescript-eslint/[email protected]([email protected]4.0([email protected]))([email protected])([email protected])':
'@typescript-eslint/[email protected]([email protected]3.0([email protected]))([email protected])([email protected])':
dependencies:
'@typescript-eslint/types': 8.72.1
'@typescript-eslint/typescript-estree': 8.72.1([email protected])([email protected])
'@typescript-eslint/utils': 8.72.1([email protected]4.0([email protected]))([email protected])([email protected])
'@typescript-eslint/utils': 8.72.1([email protected]3.0([email protected]))([email protected])([email protected])
debug: 4.4.3([email protected])
eslint: 10.14.0([email protected])
eslint: 10.13.0([email protected])
ts-api-utils: 2.5.0([email protected])
typescript: 6.0.3
transitivePeerDependencies:
@@ -11150,13 +11150,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/[email protected]([email protected]4.0([email protected]))([email protected])([email protected])':
'@typescript-eslint/[email protected]([email protected]3.0([email protected]))([email protected])([email protected])':
dependencies:
'@eslint-community/eslint-utils': 4.10.1([email protected]4.0([email protected]))
'@eslint-community/eslint-utils': 4.10.1([email protected]3.0([email protected]))
'@typescript-eslint/scope-manager': 8.72.1
'@typescript-eslint/types': 8.72.1
'@typescript-eslint/typescript-estree': 8.72.1([email protected])([email protected])
eslint: 10.14.0([email protected])
eslint: 10.13.0([email protected])
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@@ -11804,7 +11804,7 @@ snapshots:
[email protected]:
dependencies:
pascal-case: 3.1.2
tslib: 2.9.1
tslib: 2.8.1
[email protected]: {}
@@ -12184,7 +12184,7 @@ snapshots:
[email protected]:
dependencies:
no-case: 3.0.4
tslib: 2.9.1
tslib: 2.8.1
[email protected]:
dependencies:
@@ -12216,7 +12216,7 @@ snapshots:
[email protected](patch_hash=4c014bd1952b4d1f21311ed2212dc87192e70b6e782597ecc7a485270247bbb5):
dependencies:
tslib: 2.9.1
tslib: 2.8.1
zrender: 6.1.0
[email protected]:
@@ -12392,9 +12392,9 @@ snapshots:
[email protected]: {}
[email protected]([email protected]4.0([email protected])):
[email protected]([email protected]3.0([email protected])):
dependencies:
eslint: 10.14.0([email protected])
eslint: 10.13.0([email protected])
[email protected]([email protected]):
dependencies:
@@ -12418,12 +12418,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
[email protected](@typescript-eslint/[email protected]([email protected]4.0([email protected]))([email protected])([email protected]))([email protected]4.0([email protected]))([email protected]):
[email protected](@typescript-eslint/[email protected]([email protected]3.0([email protected]))([email protected])([email protected]))([email protected]3.0([email protected]))([email protected]):
dependencies:
'@typescript-eslint/types': 8.72.1
comment-parser: 1.4.8
debug: 4.4.3([email protected])
eslint: 10.14.0([email protected])
eslint: 10.13.0([email protected])
eslint-import-context: 0.1.9([email protected])
is-glob: 4.0.3
minimatch: 10.2.6
@@ -12431,39 +12431,39 @@ snapshots:
stable-hash-x: 0.2.0
unrs-resolver: 1.12.2
optionalDependencies:
'@typescript-eslint/utils': 8.72.1([email protected]4.0([email protected]))([email protected])([email protected])
'@typescript-eslint/utils': 8.72.1([email protected]3.0([email protected]))([email protected])([email protected])
transitivePeerDependencies:
- supports-color
[email protected]([email protected]4.0([email protected])):
[email protected]([email protected]3.0([email protected])):
dependencies:
'@thepassle/axobject-query': 4.0.0
aria-query: 5.3.2
axe-core: 4.13.0
dom5: 3.0.1
emoji-regex: 10.6.0
eslint: 10.14.0([email protected])
eslint-plugin-lit: 2.3.1([email protected]4.0([email protected]))
eslint: 10.13.0([email protected])
eslint-plugin-lit: 2.3.1([email protected]3.0([email protected]))
eslint-rule-extender: 0.0.1
language-tags: 1.0.9
parse5: 7.3.0
parse5-htmlparser2-tree-adapter: 6.0.1
[email protected]([email protected]4.0([email protected])):
[email protected]([email protected]3.0([email protected])):
dependencies:
eslint: 10.14.0([email protected])
eslint: 10.13.0([email protected])
parse5: 8.0.1
parse5-htmlparser2-tree-adapter: 8.0.1
[email protected](@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]4.0([email protected]))([email protected])([email protected]))([email protected]4.0([email protected]))([email protected])([email protected]))([email protected]4.0([email protected])):
[email protected](@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]3.0([email protected]))([email protected])([email protected]))([email protected]3.0([email protected]))([email protected])([email protected]))([email protected]3.0([email protected])):
dependencies:
eslint: 10.14.0([email protected])
eslint: 10.13.0([email protected])
optionalDependencies:
'@typescript-eslint/eslint-plugin': 8.72.1(@typescript-eslint/[email protected]([email protected]4.0([email protected]))([email protected])([email protected]))([email protected]4.0([email protected]))([email protected])([email protected])
'@typescript-eslint/eslint-plugin': 8.72.1(@typescript-eslint/[email protected]([email protected]3.0([email protected]))([email protected])([email protected]))([email protected]3.0([email protected]))([email protected])([email protected])
[email protected]([email protected]4.0([email protected])):
[email protected]([email protected]3.0([email protected])):
dependencies:
eslint: 10.14.0([email protected])
eslint: 10.13.0([email protected])
is-valid-element-name: 1.0.0
js-levenshtein-esm: 2.0.0
@@ -12480,9 +12480,9 @@ snapshots:
[email protected]: {}
[email protected]4.0([email protected]):
[email protected]3.0([email protected]):
dependencies:
'@eslint-community/eslint-utils': 4.10.1([email protected]4.0([email protected]))
'@eslint-community/eslint-utils': 4.10.1([email protected]3.0([email protected]))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.23.5([email protected])
'@eslint/config-helpers': 0.7.0
@@ -13137,7 +13137,7 @@ snapshots:
'@formatjs/ecma402-abstract': 2.3.4
'@formatjs/fast-memoize': 2.2.7
'@formatjs/icu-messageformat-parser': 2.11.2
tslib: 2.9.1
tslib: 2.8.1
[email protected]:
dependencies:
@@ -13628,7 +13628,7 @@ snapshots:
[email protected]:
dependencies:
tslib: 2.9.1
tslib: 2.8.1
[email protected]: {}
@@ -13849,7 +13849,7 @@ snapshots:
no-[email protected]:
dependencies:
lower-case: 2.0.2
tslib: 2.9.1
tslib: 2.8.1
[email protected]: {}
@@ -14170,7 +14170,7 @@ snapshots:
[email protected]:
dependencies:
dot-case: 3.0.4
tslib: 2.9.1
tslib: 2.8.1
[email protected]:
dependencies:
@@ -14220,7 +14220,7 @@ snapshots:
[email protected]:
dependencies:
no-case: 3.0.4
tslib: 2.9.1
tslib: 2.8.1
[email protected]: {}
@@ -14289,7 +14289,7 @@ snapshots:
[email protected]: {}
[email protected].7:
[email protected].5:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
@@ -14566,7 +14566,7 @@ snapshots:
[email protected]:
dependencies:
tslib: 2.9.1
tslib: 2.8.1
[email protected]: {}
@@ -14809,7 +14809,7 @@ snapshots:
ip-address: 10.7.2
smart-buffer: 4.2.0
[email protected](patch_hash=f25f02dcc8720341c3cfac312e0502081328108270852ad039187c2c5e2ea432): {}
[email protected](patch_hash=f2ec2ae6877dfc201c542b4f9ded1d03831258bf328ae7bba876d14ce4508a06): {}
[email protected]: {}
@@ -15176,7 +15176,7 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
tslib@2.9.1: {}
tslib@2.8.1: {}
[email protected]([email protected]):
dependencies:
@@ -15243,13 +15243,13 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
[email protected]([email protected]4.0([email protected]))([email protected])([email protected]):
[email protected]([email protected]3.0([email protected]))([email protected])([email protected]):
dependencies:
'@typescript-eslint/eslint-plugin': 8.72.1(@typescript-eslint/[email protected]([email protected]4.0([email protected]))([email protected])([email protected]))([email protected]4.0([email protected]))([email protected])([email protected])
'@typescript-eslint/parser': 8.72.1([email protected]4.0([email protected]))([email protected])([email protected])
'@typescript-eslint/eslint-plugin': 8.72.1(@typescript-eslint/[email protected]([email protected]3.0([email protected]))([email protected])([email protected]))([email protected]3.0([email protected]))([email protected])([email protected])
'@typescript-eslint/parser': 8.72.1([email protected]3.0([email protected]))([email protected])([email protected])
'@typescript-eslint/typescript-estree': 8.72.1([email protected])([email protected])
'@typescript-eslint/utils': 8.72.1([email protected]4.0([email protected]))([email protected])([email protected])
eslint: 10.14.0([email protected])
'@typescript-eslint/utils': 8.72.1([email protected]3.0([email protected]))([email protected])([email protected])
eslint: 10.13.0([email protected])
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@@ -15872,7 +15872,7 @@ snapshots:
[email protected]:
dependencies:
tslib: 2.9.1
tslib: 2.8.1
[email protected]:
dependencies:
+1 -1
View File
@@ -26,7 +26,7 @@ overrides:
lit-html: 3.3.3
clean-css: 5.3.3
"@lit/reactive-element": 2.1.2
tslib: 2.9.1
tslib: 2.8.1
patchedDependencies:
"@material/[email protected]": patches/@[email protected]
+1 -18
View File
@@ -13,9 +13,6 @@
"enabledManagers": ["npm", "nvm", "custom.regex", "github-actions"],
"minimumReleaseAge": "3 days",
"postUpdateOptions": ["pnpmDedupe"],
"constraints": {
"ghActionsLock": "v0.4.1"
},
"lockFileMaintenance": {
"description": ["Run after patch releases but before next beta"],
"enabled": true,
@@ -62,26 +59,12 @@
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.+)$"
},
{
"description": "Keep gh-actions-lock in CI and Renovate's lockfile generator on the same version",
"customType": "regex",
"managerFilePatterns": [
"/^\\.github/workflows/actions-lock\\.yaml$/",
"/^renovate\\.json$/"
],
"matchStrings": [
"GH_ACTIONS_LOCK_VERSION: (?<currentValue>v\\d+\\.\\d+\\.\\d+)",
"\"ghActionsLock\": \"(?<currentValue>v\\d+\\.\\d+\\.\\d+)\""
],
"depNameTemplate": "github/gh-actions-lock",
"datasourceTemplate": "github-releases"
},
{
"description": "Keep Playwright CI container image up to date",
"customType": "regex",
"managerFilePatterns": ["/^\\.github/workflows/e2e\\.yaml$/"],
"matchStrings": [
"mcr\\.microsoft\\.com/playwright:(?<currentValue>v\\d+\\.\\d+\\.\\d+-noble)(?:@(?<currentDigest>sha256:[a-f0-9]{64}))?"
"mcr\\.microsoft\\.com/playwright:(?<currentValue>v\\d+\\.\\d+\\.\\d+-noble)"
],
"depNameTemplate": "mcr.microsoft.com/playwright",
"datasourceTemplate": "docker",
+1 -1
View File
@@ -1,5 +1,5 @@
// The attributes that hold the address of a link or an image
export const URL_ATTRIBUTES = [
const URL_ATTRIBUTES = [
["a", "href"],
["img", "src"],
["source", "src"],
+6 -11
View File
@@ -16,8 +16,8 @@ import {
// other one is built in the browser. The attribution comes from the TileJSON,
// deliberately: it follows whoever serves the tiles.
const SHIPPED_STYLES = {
light: `${__STATIC_PATH__}map/light.json`,
dark: `${__STATIC_PATH__}map/dark.json`,
light: "/static/map/light.json",
dark: "/static/map/dark.json",
} as const;
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
@@ -92,12 +92,8 @@ const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
return style;
};
// The origin that serves the frontend's files: the page, except for the demo
// embed on another site
const filesBase = () => new URL(__STATIC_PATH__, location.href);
// MapLibre rejects a relative sprite URL
const absoluteSprite = (url: string) => new URL(url, filesBase()).href;
const absoluteSprite = (url: string) => new URL(url, location.href).href;
// Shared only while in flight, or a style that failed once would never be
// fetched again. Text rather than a parsed style: MapLibre mutates what it
@@ -129,7 +125,7 @@ export const loadStyle = async (
).buildMapStyle(
mapStyle.palette,
mapStyle.options ?? {},
mapStyle.styleOptions
mapStyle.baseColors
);
if (typeof style.sprite === "string") {
@@ -145,15 +141,14 @@ export const loadStyle = async (
// Global to MapLibre; set once before the first map is created. Absolute,
// because on Cast the page is not served from the instance. The URL comes
// from the build: the worker is an entry of its own, next to the app. MapLibre
// starts a worker from another origin itself.
// from the build: the worker is an entry of its own, next to the app.
let workerUrlSet = false;
export const ensureWorkerUrl = (setUrl: typeof setWorkerUrl) => {
if (workerUrlSet) {
return;
}
workerUrlSet = true;
setUrl(new URL(__MAPLIBRE_WORKER_URL__, filesBase()).href);
setUrl(new URL(__MAPLIBRE_WORKER_URL__, location.href).href);
};
const createVectorLayer = async (
+9 -5
View File
@@ -16,7 +16,7 @@ import { finalizeMapStyle } from "./map-style-transforms";
*/
// Versioned: /static is served with a month of max-age, so a cached manifest
// could otherwise pair a new build with the sprite sheet of an old one.
const STYLE_URLS_PATH = `${__STATIC_PATH__}map/urls.json?v=${__VERSION__}`;
const STYLE_URLS_PATH = `/static/map/urls.json?v=${__VERSION__}`;
let urls: Promise<OsmOptions["urls"]> | undefined;
@@ -35,7 +35,7 @@ const styleUrls = () => {
export const buildMapStyle = async (
palette: MapPalette,
options: Record<string, unknown>,
styleOptions?: Record<string, unknown>
baseColors?: Record<string, string>
): Promise<StyleSpecification> => {
const [{ osm }, resolvedUrls] = await Promise.all([
import("@versatiles/style"),
@@ -50,9 +50,13 @@ export const buildMapStyle = async (
// The builder throws on an option it does not know and on a color it cannot
// parse. Those come from a card config or a theme, so one typo would
// otherwise drop the whole card to raster tiles. Step back to the style as it
// comes - colors and adjustments included, or the default would come out as
// the builder's unpainted cartography - and only then to the bare palette.
const attempts = [options, ...(styleOptions ? [styleOptions] : []), {}];
// ships - colors included, or the default would come out as the builder's
// unpainted cartography - and only then to the bare palette.
const attempts = [
options,
...(baseColors ? [{ colors: baseColors }] : []),
{},
];
let style: StyleSpecification | undefined;
for (const attempt of attempts) {
try {
@@ -78,7 +78,7 @@ const POSITIONS: Record<
bottomright: "bottom-right",
};
const MAPLIBRE_CSS_URL = `${__STATIC_PATH__}map/maplibre-gl.css`;
const MAPLIBRE_CSS_URL = "/static/map/maplibre-gl.css";
// Fully hidden behind the globe, not MapLibre's faint ghost
const MARKER_OPTIONS = { opacityWhenCovered: 0 };
+16 -74
View File
@@ -1,12 +1,11 @@
// The vector cartographies the map can draw. Each is a light and a dark
// palette; the theme mode picks the half, unless the style only has one.
// palette; the theme mode picks the half.
//
// Only the default pair is generated at build time (build-scripts/gulp/
// map-assets.js); everything else is built in the browser from the same
// builder, against the tiles and glyphs core's proxy already serves.
import { HA_MAP_COLORS, HA_MAP_COLORS_DARK } from "./ha-map-palette";
import { NEON_MAP_COLORS_DARK, NEON_MAP_OPTIONS } from "./neon-map-palette";
/** Style ids, in the order the card editor offers them */
export const MAP_STYLES = [
@@ -16,7 +15,6 @@ export const MAP_STYLES = [
"muted",
"gray",
"toner",
"neon",
] as const;
export type MapStyle = (typeof MAP_STYLES)[number];
@@ -28,19 +26,13 @@ type VersatilesPalette = "colorful" | "natural" | "muted" | "gray" | "toner";
export type MapPalette = VersatilesPalette | `${VersatilesPalette}-dark`;
/**
* A cartography plus what is painted over it; the VersaTiles palettes have
* nothing. The color sets say which halves a style has: both, or neither, and
* the theme mode picks; only one, and that is the half the map draws.
*/
/** A cartography plus the colors painted over it; only the default has any */
const STYLE_PALETTES: Record<
MapStyle,
{
palette: VersatilesPalette;
colors?: Record<string, string>;
colorsDark?: Record<string, string>;
/** Builder options the style comes with, by option group */
options?: Record<string, Record<string, unknown>>;
}
> = {
default: {
@@ -48,11 +40,6 @@ const STYLE_PALETTES: Record<
colors: HA_MAP_COLORS,
colorsDark: HA_MAP_COLORS_DARK,
},
neon: {
palette: "colorful",
colorsDark: NEON_MAP_COLORS_DARK,
options: NEON_MAP_OPTIONS,
},
colorful: { palette: "colorful" },
natural: { palette: "natural" },
muted: { palette: "muted" },
@@ -109,12 +96,11 @@ export interface ResolvedMapStyle {
/** Fetched rather than built: the default, while nothing was added to it */
shipped?: "light" | "dark";
/**
* The style as it comes: its colors and its own adjustments, without what
* the theme or the card painted over them. What to fall back on when the
* builder rejects an adjustment, so a bad value costs the card's adjustments
* rather than the style.
* The style's own colors, without what the theme or the card painted over
* them. What to fall back on when the builder rejects an adjustment, so a
* bad value costs the adjustments rather than the cartography.
*/
styleOptions?: Record<string, unknown>;
baseColors?: Record<string, string>;
}
const isStyle = (value: unknown): value is MapStyle =>
@@ -124,31 +110,9 @@ export const isCustomMapStyle = (
style: MapStyleConfig | undefined
): style is CustomMapStyleConfig => typeof style === "object" && style !== null;
const toStyle = (style: MapStyleConfig | undefined): MapStyle => {
const named = isCustomMapStyle(style) ? style.base : style;
return isStyle(named) ? named : DEFAULT_MAP_STYLE;
};
/**
* Whether the map comes out dark: the theme mode's answer, unless the style
* only has the one half. What the map's own chrome and the theme tokens have
* to follow, rather than the theme mode.
*/
export const isDarkMapStyle = (
style: MapStyleConfig | undefined,
darkMode: boolean
): boolean => {
const { colors, colorsDark } = STYLE_PALETTES[toStyle(style)];
if (Boolean(colors) !== Boolean(colorsDark)) {
return Boolean(colorsDark);
}
return darkMode;
};
/** The half to draw; `dark` is the map's, from isDarkMapStyle */
export const paletteFor = (style: MapStyle, dark: boolean): MapPalette => {
export const paletteFor = (style: MapStyle, darkMode: boolean): MapPalette => {
const { palette } = STYLE_PALETTES[style];
return dark ? `${palette}-dark` : palette;
return darkMode ? `${palette}-dark` : palette;
};
// An allowlist, not a passthrough: the builder also takes the tile, glyph and
@@ -234,33 +198,19 @@ export const resolveMapStyle = (
themeColors?: Record<string, string>
): ResolvedMapStyle => {
const custom = isCustomMapStyle(style) ? style : undefined;
const mapStyle = toStyle(style);
// A dark-only style picks the dark half of everything, not just the palette.
const dark = isDarkMapStyle(style, darkMode);
const named = custom ? custom.base : style;
const mapStyle = isStyle(named) ? named : DEFAULT_MAP_STYLE;
const palette = paletteFor(mapStyle, dark);
const options = custom ? builderOptions(custom, dark) : {};
const palette = paletteFor(mapStyle, darkMode);
const options = custom ? builderOptions(custom, darkMode) : {};
// Before the colors below are merged in, so it answers whether the card
// asked for anything of its own.
const cardAdjusted = Object.keys(options).length > 0;
// The style's own adjustments go under the card's, group by group, so a
// card that sets one recolor value keeps the style's others.
const {
colors,
colorsDark,
options: styleOptions,
} = STYLE_PALETTES[mapStyle];
for (const [group, values] of Object.entries(styleOptions ?? {})) {
options[group] = {
...values,
...(options[group] as Record<string, unknown> | undefined),
};
}
// Bottom up: the style's colors, what the theme repaints, then what the card
// names - so a card that sets one color keeps the rest of the look.
const styleColors = dark ? colorsDark : colors;
const { colors, colorsDark } = STYLE_PALETTES[mapStyle];
const styleColors = (darkMode ? colorsDark : colors) ?? colors;
if (styleColors || themeColors) {
options.colors = {
...styleColors,
@@ -273,19 +223,11 @@ export const resolveMapStyle = (
return { palette };
}
const own = styleColors || styleOptions;
const base = own
? {
styleOptions: {
...styleOptions,
...(styleColors && { colors: styleColors }),
},
}
: {};
const base = styleColors ? { baseColors: styleColors } : {};
// Anything added on top of the shipped default has to be built here.
const untouched = !themeColors && !cardAdjusted;
return mapStyle === DEFAULT_MAP_STYLE && untouched
? { palette, options, ...base, shipped: dark ? "dark" : "light" }
? { palette, options, ...base, shipped: darkMode ? "dark" : "light" }
: { palette, options, ...base };
};
-13
View File
@@ -1,13 +0,0 @@
import { isAndroidDevice } from "../../util/is_android";
import { isIosDevice } from "../../util/is_ios";
export const navigationUrl = (latitude: number, longitude: number): string => {
const point = `${latitude},${longitude}`;
if (isIosDevice()) {
return `https://maps.apple.com/?daddr=${point}`;
}
if (isAndroidDevice()) {
return `geo:${point}?q=${point}`;
}
return `https://www.openstreetmap.org/directions?to=${encodeURIComponent(point)}#map=15/${latitude}/${longitude}`;
};
-64
View File
@@ -1,64 +0,0 @@
// The "neon" style: the VersaTiles "colorful" cartography repainted as a night
// map, glowing roads on near-black ground. A dark set and no light one, which
// is what makes the style dark whatever the theme mode.
export const NEON_MAP_COLORS_DARK = {
background: "#07021a",
land: "#07021a",
water: "#13215e",
labelWater: "#00e5ff",
glacier: "#1b2a6b",
natureWood: "#0d1f2e",
natureGrass: "#101a33",
naturePark: "#12203a",
natureLeisure: "#111a30",
natureAgriculture: "#0d1528",
natureWetland: "#0e2440",
natureSand: "#1a1230",
natureRock: "#140c28",
areaResidential: "#0c0524",
areaCommercial: "#140430",
areaIndustrial: "#10062a",
areaWaste: "#160a2e",
areaBurial: "#0d0a26",
siteParking: "#0f0728",
siteSports: "#0a1c2c",
siteEducation: "#160a34",
siteHospital: "#2a0a2a",
siteDanger: "#ff1744",
sitePrison: "#1c0a28",
siteConstruction: "#3a1d5c",
building: "#160a33",
buildingBg: "#8a2be2",
roadStreet: "#00e5ff",
roadStreetBg: "#062b52",
roadSecondary: "#8f7bff",
roadSecondaryBg: "#241058",
roadTrunk: "#ff6ec7",
roadTrunkBg: "#4a0f38",
roadMotorway: "#ff2d95",
roadMotorwayBg: "#5c0033",
transitRail: "#7b2cff",
transitSubway: "#b14cff",
transitCycle: "#00ffa3",
transitFoot: "#2e2a55",
boundary: "#39ff14",
boundaryDisputed: "#1f7a0d",
label: "#ff9ad5",
labelHalo: "rgba(7,2,26,0.9)",
labelSymbol: "#ff9ad5",
labelPoi: "#00e5ff",
labelShield: "#12002a",
labelHousenumber: "rgba(0,229,255,0.35)",
};
/** Builder options the style comes with, under whatever a card adds */
export const NEON_MAP_OPTIONS = {
recolor: { saturate: 0.35, contrast: 1.15 },
text: {
transform: "uppercase",
letterSpacing: 0.12,
haloWidth: 2,
haloBlur: 0,
},
};
+1 -10
View File
@@ -1,4 +1,3 @@
import { SYSTEM_LOG_LEVELS, type SystemLogLevel } from "../../data/system_log";
import {
decodeQueryParams,
hasQueryParams,
@@ -18,7 +17,7 @@ export const logSearchQueryParamConfig = {
// `raw` shows the full Home Assistant Core log instead of the errors.
export const logsQueryParamConfig = {
string: ["provider", "search", "filter"],
list: ["integration", "level"],
list: ["integration"],
boolean: [{ key: "raw", trueValue: "1" }],
} as const satisfies QueryParamConfig;
@@ -29,19 +28,13 @@ export interface LogsState {
search: string;
// Only apply to the Home Assistant Core log
integrations: string[];
// Only apply to the Home Assistant Core errors, not the raw log
levels: SystemLogLevel[];
raw: boolean;
}
const isLevel = (level: string): level is SystemLogLevel =>
(SYSTEM_LOG_LEVELS as readonly string[]).includes(level);
export const DEFAULT_LOGS_STATE: LogsState = {
provider: DEFAULT_LOG_PROVIDER,
search: "",
integrations: [],
levels: [],
raw: false,
};
@@ -60,7 +53,6 @@ export const logsQueryParams = (state: LogsState): LogsQueryParams => ({
search: state.search,
integration:
state.provider === DEFAULT_LOG_PROVIDER ? state.integrations : undefined,
level: state.provider === DEFAULT_LOG_PROVIDER ? state.levels : undefined,
raw: state.provider === DEFAULT_LOG_PROVIDER && state.raw,
});
@@ -79,7 +71,6 @@ export const logsLinkFromUrl = (
provider,
search: params.search ?? params.filter ?? "",
integrations: core ? (params.integration?.filter(Boolean) ?? []) : [],
levels: core ? (params.level?.filter(isLevel) ?? []) : [],
raw: core && !!params.raw,
};
};
@@ -67,88 +67,3 @@ export const itemTooltipPosition: TooltipPositionCallback = (
return [x, y];
};
const ECHARTS_TOOLTIP_GAP_PX = 20;
export const WINDOW_MARGIN_PX = 8;
const fitInto = (
pos: number,
size: number,
alternatives: number[],
min: number,
max: number
) =>
size > max - min
? min
: ([pos, ...alternatives].find((p) => p >= min && p + size <= max) ??
Math.max(min, Math.min(pos, max - size)));
/**
* ECharts' default placement (or the chart-confined one), moved into the
* window when it would leave it. The other side of the cursor is tried before
* clamping, below first vertically. A tooltip taller than the window starts at
* its top. A confined tooltip stays in the chart, leaving it only as far as the
* window needs.
*/
export const windowTooltipPosition =
(
getChartRect: () => DOMRect | undefined,
confine: boolean
): TooltipPositionCallback =>
(point, _params, _dom, _rect, size) => {
const [cursorX, cursorY] = point;
const [viewW, viewH] = size.viewSize;
const [tipW, tipH] = size.contentSize;
const right = cursorX + ECHARTS_TOOLTIP_GAP_PX;
const left = cursorX - ECHARTS_TOOLTIP_GAP_PX - tipW;
const below = cursorY + ECHARTS_TOOLTIP_GAP_PX;
const above = cursorY - ECHARTS_TOOLTIP_GAP_PX - tipH;
let x = right + tipW + 2 > viewW ? left : right;
let y = below + tipH > viewH ? above : below;
if (confine) {
x = Math.max(0, Math.min(x, viewW - tipW));
y = Math.max(0, Math.min(y, viewH - tipH));
}
const rect = getChartRect();
if (!rect?.height) {
return [x, y];
}
const fit = (
pos: number,
tipSize: number,
alternatives: number[],
windowMin: number,
windowMax: number,
viewSize: number
) => {
if (!confine) {
return fitInto(pos, tipSize, alternatives, windowMin, windowMax);
}
const min = Math.max(0, windowMin);
const max = Math.min(viewSize, windowMax);
return tipSize > max - min
? fitInto(pos, tipSize, [], windowMin, windowMax)
: fitInto(pos, tipSize, alternatives, min, max);
};
const { clientWidth, clientHeight } = document.documentElement;
return [
fit(
x,
tipW,
[right, left],
WINDOW_MARGIN_PX - rect.left,
clientWidth - WINDOW_MARGIN_PX - rect.left,
viewW
),
fit(
y,
tipH,
[below, above],
WINDOW_MARGIN_PX - rect.top,
clientHeight - WINDOW_MARGIN_PX - rect.top,
viewH
),
];
};
+1 -12
View File
@@ -68,7 +68,6 @@ import { ZOOM_SLIDER_HEIGHT } from "./ha-chart-zoom-slider";
import { formatTimeLabel } from "./axis-label";
import type { ChartSonification } from "./chart-sonification";
import { canSonifyChart, sonifyChart } from "./chart-sonification";
import { windowTooltipPosition } from "./chart-tooltip-position";
import { downSampleLineData } from "./down-sample";
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
@@ -1253,19 +1252,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
const processTooltip = (tooltip: HaTooltipOption): TooltipOption => {
const next = convertHaTooltipFormatter(tooltip);
if (isMobile) {
next.appendTo = undefined;
}
if (next.position === undefined) {
// ECharts confines after the position callback, which would undo the
// window fit, so the callback confines itself
next.position = windowTooltipPosition(
() => this._chartContainer?.getBoundingClientRect(),
isMobile || next.confine === true
);
next.confine = false;
} else if (isMobile) {
// mobile charts are full width so we need to confine the tooltip to the chart
next.confine = true;
next.appendTo = undefined;
}
if (isMobile || this._touchInput) {
next.triggerOn = "click";
+4 -10
View File
@@ -31,14 +31,8 @@ export interface NetworkNode {
color?: string;
borderColor?: string;
borderWidth?: number;
opacity?: number;
};
label?: {
position?: "left" | "right" | "top" | "bottom";
show?: boolean;
fontWeight?: "normal" | "bold";
fontSize?: number;
};
label?: { position: "left" | "right" | "top" | "bottom" };
fixed?: boolean;
/**
* Distance from the center, where 0 is the center and 1 is the edge
@@ -57,7 +51,6 @@ export interface NetworkLink {
width?: number;
color?: string;
type?: "solid" | "dashed" | "dotted";
opacity?: number;
};
symbolSize?: number | number[];
symbol?: string;
@@ -293,8 +286,9 @@ export class HaNetworkGraph extends LitElement {
return node.name ?? "";
},
rich: {
// inherits the node label font, so a node can override it
primary: {},
primary: {
fontSize: 12,
},
secondary: {
fontSize: 12,
color: getComputedStyle(document.body).getPropertyValue(
+42 -55
View File
@@ -22,8 +22,6 @@ export interface Node {
color?: string;
passThrough?: boolean;
entityId?: string;
// Nodes of one group take a single position when ordering their children
group?: string;
}
export interface Link {
source: string;
@@ -48,46 +46,6 @@ const NODE_SIZE = 30;
const LABEL_MIN_MARGIN = 5;
const BIDI_MARKS = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
// Links without a value take the remaining capacity of their nodes, in order.
export const fillSankeyLinkValues = (
nodes: Node[],
rawLinks: Link[]
): ProcessedLink[] => {
const accountedIn = new Map<string, number>();
const accountedOut = new Map<string, number>();
const links: ProcessedLink[] = [];
rawLinks.forEach((link) => {
const sourceNode = nodes.find((n) => n.id === link.source);
const targetNode = nodes.find((n) => n.id === link.target);
if (!sourceNode || !targetNode) {
return;
}
const sourceAccounted = accountedOut.get(sourceNode.id) || 0;
const targetAccounted = accountedIn.get(targetNode.id) || 0;
// if no value is provided, we infer it from the remaining capacity of the source and target nodes
const sourceRemaining = sourceNode.value - sourceAccounted;
const targetRemaining = targetNode.value - targetAccounted;
// ensure the value is not greater than the remaining capacity of the nodes
const value = Math.min(
link.value ?? sourceRemaining,
sourceRemaining,
targetRemaining
);
accountedIn.set(targetNode.id, targetAccounted + value);
accountedOut.set(sourceNode.id, sourceAccounted + value);
if (value > 0) {
links.push({
...link,
value,
});
}
});
return links;
};
@customElement("ha-sankey-chart")
export class HaSankeyChart extends LitElement {
@property({ attribute: false }) public data: SankeyChartData = {
@@ -232,7 +190,7 @@ export class HaSankeyChart extends LitElement {
);
}
});
const links = fillSankeyLinkValues(filteredNodes, data.links);
const links = this._processLinks(filteredNodes, data.links);
const sectionWidth = width / indexes.length;
const labelSpace = sectionWidth - NODE_SIZE - LABEL_DISTANCE;
// Two-line values can wrap the unit onto a third line; keep that text inside the chart.
@@ -346,31 +304,60 @@ export class HaSankeyChart extends LitElement {
private _createData = memoizeOne(this._computeData);
private _processLinks(nodes: Node[], rawLinks: Link[]) {
const accountedIn = new Map<string, number>();
const accountedOut = new Map<string, number>();
const links: ProcessedLink[] = [];
rawLinks.forEach((link) => {
const sourceNode = nodes.find((n) => n.id === link.source);
const targetNode = nodes.find((n) => n.id === link.target);
if (!sourceNode || !targetNode) {
return;
}
const sourceAccounted = accountedOut.get(sourceNode.id) || 0;
const targetAccounted = accountedIn.get(targetNode.id) || 0;
// if no value is provided, we infer it from the remaining capacity of the source and target nodes
const sourceRemaining = sourceNode.value - sourceAccounted;
const targetRemaining = targetNode.value - targetAccounted;
// ensure the value is not greater than the remaining capacity of the nodes
const value = Math.min(
link.value ?? sourceRemaining,
sourceRemaining,
targetRemaining
);
accountedIn.set(targetNode.id, targetAccounted + value);
accountedOut.set(sourceNode.id, sourceAccounted + value);
if (value > 0) {
links.push({
...link,
value,
});
}
});
return links;
}
private _findParentIndex(id: string, links: Link[], sections: Node[][]) {
const parents = links.filter((l) => l.target === id).map((l) => l.source);
if (parents.length === 0) {
return -1;
}
const countedGroups = new Set<string>();
let sum = 0;
let count = 0;
for (const parent of parents) {
let offset = 0;
for (let i = sections.length - 1; i >= 0; i--) {
const positions = [...new Set(sections[i].map((n) => n.group ?? n.id))];
const node = sections[i].find((n) => n.id === parent);
if (node) {
if (node.group !== undefined) {
if (countedGroups.has(node.group)) {
break;
}
countedGroups.add(node.group);
}
sum += offset + positions.indexOf(node.group ?? node.id);
const section = sections[i];
const index = section.findIndex((n) => n.id === parent);
if (index !== -1) {
sum += offset + index;
count++;
break;
}
offset += positions.length;
offset += section.length;
}
}
return count > 0 ? sum / count : -1;
@@ -161,8 +161,7 @@ export abstract class HaDeviceAutomationPicker<
return html`<ha-generic-picker
.label=${this.label}
.value=${value}
.disabled=${this._automations?.length === 0}
.loading=${this._automations === undefined}
.disabled=${!this._automations || this._automations.length === 0}
.getItems=${this._getItems(
value,
this._automations,
+1 -37
View File
@@ -189,10 +189,6 @@ export class HaStatisticPicker extends LitElement {
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _loadFailed = false;
private _statisticIdsRequest = 0;
@property({ attribute: "can-edit", type: Boolean }) public canEdit?: boolean;
public willUpdate(changedProps: PropertyValues<this>) {
@@ -205,36 +201,11 @@ export class HaStatisticPicker extends LitElement {
}
private async _getStatisticIds() {
// Changing the statistic types starts a new request, and only the latest
// one may update the picker
const request = ++this._statisticIdsRequest;
this._loadFailed = false;
let statisticIds: StatisticsMetaData[];
try {
statisticIds = await getStatisticIds(this._api, this.statisticTypes);
} catch {
if (request === this._statisticIdsRequest) {
this._loadFailed = true;
}
return;
}
if (request !== this._statisticIdsRequest) {
return;
}
this.statisticIds = statisticIds;
this.statisticIds = await getStatisticIds(this._api, this.statisticTypes);
this._picker?.requestUpdate();
this._valueRenderer = this._makeValueRenderer();
}
private _retryGetStatisticIds(ev: Event) {
ev.stopPropagation();
this._getStatisticIds();
}
private _getItems = () =>
this._getStatisticsItems(
this._states,
@@ -618,12 +589,6 @@ export class HaStatisticPicker extends LitElement {
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.loading=${!this._loadFailed && this.statisticIds === undefined}
.loadError=${
this._loadFailed
? this._i18n.localize("ui.components.statistic-picker.load_failed")
: undefined
}
.hideClearIcon=${this.hideClearIcon}
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
@@ -633,7 +598,6 @@ export class HaStatisticPicker extends LitElement {
"ui.components.statistic-picker.unknown"
)}
@value-changed=${this._valueChanged}
@picker-retry=${this._retryGetStatisticIds}
>
</ha-generic-picker>
`;
+1 -55
View File
@@ -1,11 +1,10 @@
import type { ContextType } from "@lit/context";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import {
@@ -26,9 +25,6 @@ import type { IntegrationManifest } from "../data/integration";
import { domainToName } from "../data/integration";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-badge";
import "./ha-svg-icon";
import "./ha-tooltip";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
@@ -36,14 +32,6 @@ import type { HaListSelectable } from "./list/ha-list-selectable";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
export interface IntegrationFilterCount {
key: string;
path: string;
count: number;
label: string;
color?: string;
}
@customElement("ha-filter-integrations")
export class HaFilterIntegrations extends LitElement {
@property({ attribute: false }) public value?: string[];
@@ -52,12 +40,6 @@ export class HaFilterIntegrations extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
/** Counts to show beside each integration, by domain. */
@property({ attribute: false }) public counts?: Record<
string,
IntegrationFilterCount[]
>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@@ -147,7 +129,6 @@ export class HaFilterIntegrations extends LitElement {
brand-fallback
></ha-domain-icon>
<span slot="headline">${integration.name}</span>
${this._renderCounts(integration.domain)}
</ha-list-item-option>`
)}
</ha-list-selectable>`
@@ -159,32 +140,6 @@ export class HaFilterIntegrations extends LitElement {
`;
}
private _renderCounts(domain: string): TemplateResult | typeof nothing {
const counts = this.counts?.[domain];
if (!counts?.length) {
return nothing;
}
return html`<span slot="supporting-text" class="counts">
${counts.map(
(item) =>
html`<ha-badge
id="count-${domain}-${item.key}"
role="img"
aria-label=${item.label}
style=${styleMap({ "--badge-color": item.color })}
>
<ha-svg-icon slot="icon" .path=${item.path}></ha-svg-icon>
${item.count}
</ha-badge>
<ha-tooltip for="count-${domain}-${item.key}">
${item.label}
</ha-tooltip>`
)}
</span>`;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -294,15 +249,6 @@ export class HaFilterIntegrations extends LitElement {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
.counts {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-1);
margin-top: var(--ha-space-1);
--ha-badge-size: 22px;
--ha-badge-icon-size: 14px;
--ha-badge-font-size: var(--ha-font-size-xs);
}
`,
];
}
+43 -161
View File
@@ -2,7 +2,7 @@ import "@home-assistant/webawesome/dist/components/popover/popover";
import type WaPopover from "@home-assistant/webawesome/dist/components/popover/popover";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { ContextType } from "@lit/context";
import { mdiPlaylistPlus, mdiRefresh } from "@mdi/js";
import { mdiPlaylistPlus } from "@mdi/js";
import {
css,
html,
@@ -17,15 +17,13 @@ import { styleMap } from "lit/directives/style-map";
import { tinykeys } from "tinykeys";
import { consume } from "../common/decorators/consume";
import { fireEvent } from "../common/dom/fire_event";
import { configContext, internationalizationContext } from "../data/context";
import { configContext } from "../data/context";
import { PickerMixin } from "../mixins/picker-mixin";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import { isIosApp } from "../util/is_ios";
import "./ha-alert";
import "./ha-bottom-sheet";
import "./ha-button";
import "./ha-combo-box-item";
import "./ha-icon-button";
import "./ha-input-helper-text";
import "./ha-picker-combo-box";
import type {
@@ -35,7 +33,6 @@ import type {
} from "./ha-picker-combo-box";
import "./ha-picker-field";
import "./ha-svg-icon";
import "./skeleton/ha-skeleton";
@customElement("ha-generic-picker")
export class HaGenericPicker extends PickerMixin(LitElement) {
@@ -117,15 +114,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ type: Boolean, attribute: "no-unknown-state" })
public noUnknownState = false;
/** Shows a skeleton in place of the field while its items load. */
@property({ type: Boolean, reflect: true }) public loading = false;
/**
* Shows this error with a retry button in place of the field when its items
* failed to load. The retry button fires `picker-retry`.
*/
@property({ attribute: false }) public loadError?: string;
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@@ -136,14 +124,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@consume({ context: configContext, subscribe: true })
private _hassConfig?: ContextType<typeof configContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
// Set once loading starts, then hidden with CSS once it ends, so the
// skeleton fades out over the field
@state() private _skeletonShown = false;
@state() private _opened = false;
@state() private _pickerWrapperOpen = false;
@@ -175,14 +155,9 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _unsubscribeTinyKeys?: () => void;
protected willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("loading") && this.loading) {
this._skeletonShown = true;
}
if (
changedProperties.has("value") ||
changedProperties.has("noUnknownState") ||
changedProperties.has("loading") ||
changedProperties.has("loadError")
changedProperties.has("noUnknownState")
) {
this._setUnknownValue();
}
@@ -208,73 +183,45 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
: nothing
}
<div id="picker">
<div class="field" ?inert=${this.loading || !!this.loadError}>
<slot name="field">
${
this.addButtonLabel && !this.value
? html`<ha-button
size="s"
appearance="filled"
@click=${this.open}
.disabled=${this.disabled}
>
<ha-svg-icon
.path=${mdiPlaylistPlus}
slot="start"
></ha-svg-icon>
${this.addButtonLabel}
</ha-button>`
: html`<ha-picker-field
type="button"
class=${this._opened ? "opened" : ""}
compact
.unknown=${this._unknownValue}
.unknownItemText=${this.unknownItemText}
aria-label=${ifDefined(this.ariaLabel || this.label)}
@click=${this.open}
@clear=${this._clear}
.icon=${this.icon}
.image=${this.image}
.label=${label}
.placeholder=${this.placeholder}
.value=${this.value}
.valueRenderer=${this.valueRenderer}
.required=${this.required}
.disabled=${this.disabled}
.invalid=${this.invalid}
.hideClearIcon=${this.hideClearIcon}
>
<slot name="start"></slot>
</ha-picker-field>`
}
</slot>
</div>
${
this._skeletonShown
? html`<ha-skeleton
class="loading-skeleton"
role=${ifDefined(this.loading ? "progressbar" : undefined)}
aria-label=${ifDefined(
this.loading ? this._loadingLabel() : undefined
)}
></ha-skeleton>`
: nothing
}
${
this.loadError
? html`<div class="load-error">
<ha-alert alert-type="error">
${this.loadError}
<ha-icon-button
slot="action"
.path=${mdiRefresh}
.label=${this._i18n?.localize("ui.common.retry")}
@click=${this._retry}
></ha-icon-button>
</ha-alert>
</div>`
: nothing
}
<slot name="field">
${
this.addButtonLabel && !this.value
? html`<ha-button
size="s"
appearance="filled"
@click=${this.open}
.disabled=${this.disabled}
>
<ha-svg-icon
.path=${mdiPlaylistPlus}
slot="start"
></ha-svg-icon>
${this.addButtonLabel}
</ha-button>`
: html`<ha-picker-field
type="button"
class=${this._opened ? "opened" : ""}
compact
.unknown=${this._unknownValue}
.unknownItemText=${this.unknownItemText}
aria-label=${ifDefined(this.ariaLabel || this.label)}
@click=${this.open}
@clear=${this._clear}
.icon=${this.icon}
.image=${this.image}
.label=${label}
.placeholder=${this.placeholder}
.value=${this.value}
.valueRenderer=${this.valueRenderer}
.required=${this.required}
.disabled=${this.disabled}
.invalid=${this.invalid}
.hideClearIcon=${this.hideClearIcon}
>
<slot name="start"></slot>
</ha-picker-field>`
}
</slot>
</div>
${
this._pickerWrapperOpen || this._opened
@@ -351,25 +298,9 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
`;
}
private _loadingLabel() {
const label = this.ariaLabel || this.label || this.placeholder;
return label
? this._i18n?.localize("ui.components.generic-picker.loading", {
label,
})
: this._i18n?.localize("ui.common.loading");
}
private _retry(ev: Event) {
ev.stopPropagation();
fireEvent(this, "picker-retry");
}
private _setUnknownValue = () => {
const items = this.getItems();
if (
this.loading ||
this.loadError ||
this.noUnknownState ||
this.allowCustomValue ||
this.value === undefined ||
@@ -482,8 +413,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
}
) {
ev?.stopPropagation();
if (this.disabled || this.loading || this.loadError) {
if (this.disabled) {
return;
}
this._selectedValue = options?.selectedValue;
@@ -529,53 +459,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
display: block;
max-width: 100%;
}
/* The error shares the field's cell, so a wrapping error grows the
picker instead of covering what follows */
#picker {
position: relative;
display: grid;
}
#picker > .field,
#picker > .load-error {
grid-area: 1 / 1;
min-width: 0;
}
/* The field keeps its place under the skeleton, so nothing moves and
the two cross fade once loading ends */
.field {
transition: opacity var(--ha-animation-duration-fast, 150ms) ease-out;
}
:host([loading]) .field,
.field[inert] {
opacity: 0;
}
.loading-skeleton {
position: absolute;
inset: 0;
--ha-skeleton-border-radius: var(--ha-border-radius-sm);
transition: opacity var(--ha-animation-duration-fast, 150ms) ease-out;
}
@starting-style {
.loading-skeleton {
opacity: 0;
}
}
/* Hidden once faded out, and at once when no fade runs */
:host(:not([loading])) .loading-skeleton {
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity var(--ha-animation-duration-fast, 150ms) ease-out,
visibility 0s linear var(--ha-animation-duration-fast, 150ms);
}
.load-error {
display: flex;
align-items: center;
}
.load-error ha-alert {
flex: 1;
}
label[disabled] {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.6));
}
@@ -657,6 +540,5 @@ declare global {
interface HASSDomEvents {
"picker-closed": undefined;
"picker-opened": undefined;
"picker-retry": undefined;
}
}
-2
View File
@@ -86,7 +86,6 @@ class HaMarkdownElement extends ReactiveElement {
allowSvg: this.allowSvg,
allowDataUrl: this.allowDataUrl,
breaks: this.breaks,
rewriteUrl: Boolean(this.rewriteUrl),
});
}
@@ -100,7 +99,6 @@ class HaMarkdownElement extends ReactiveElement {
{
allowSvg: this.allowSvg,
allowDataUrl: this.allowDataUrl,
allowRelativeUrls: Boolean(this.rewriteUrl),
}
);
+1 -2
View File
@@ -178,8 +178,7 @@ export class HaNavigationPicker extends LitElement {
allow-custom-value
.placeholder=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.loading=${this._loading}
.disabled=${this._loading || this.disabled}
.required=${this.required}
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
@@ -162,7 +162,7 @@ export class HaObjectSelector extends LitElement {
description = this._formatField(item, descriptionField);
}
const reorderable = this._reorderable;
const reorderable = this.selector.object!.multiple || false;
const multiple = this.selector.object!.multiple || false;
return html`
<ha-list-item-base class="item">
@@ -212,7 +212,6 @@ export class HaObjectSelector extends LitElement {
${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="items-container">
<ha-sortable
.disabled=${!this._reorderable}
handle-selector=".handle"
draggable-selector=".item"
@item-moved=${this._itemMoved}
@@ -292,18 +291,11 @@ export class HaObjectSelector extends LitElement {
}));
});
private get _reorderable(): boolean {
return (
(this.selector.object?.multiple ?? false) &&
this.selector.object?.reorder !== false
);
}
private _itemMoved(ev) {
ev.stopPropagation();
const newIndex = ev.detail.newIndex;
const oldIndex = ev.detail.oldIndex;
if (!this._reorderable) {
if (!this.selector.object!.multiple) {
return;
}
const newValue = ensureArray(this.value ?? []).concat();
+1 -187
View File
@@ -1,14 +1,7 @@
import TabGroup from "@home-assistant/webawesome/dist/components/tab-group/tab-group";
import { css, type CSSResultGroup, type PropertyValues } from "lit";
import { css, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
import type { SortableEvent } from "sortablejs";
import { DragScrollController } from "../common/controllers/drag-scroll-controller";
import { fireEvent } from "../common/dom/fire_event";
import type { SortableInstance } from "../resources/sortable";
const TOUCH_DRAG_DELAY_MS = 300;
const AUTO_SCROLL_SENSITIVITY = 30;
@customElement("ha-tab-group")
export class HaTabGroup extends TabGroup {
@@ -20,199 +13,24 @@ export class HaTabGroup extends TabGroup {
@property({ attribute: "tab-only", type: Boolean }) tabOnly = true;
/**
* Allow reordering tabs by dragging or with Alt+ArrowUp/ArrowDown. Fires
* `item-moved`; the owner applies the new order.
*/
@property({ type: Boolean }) public sortable = false;
/** Drag handle that starts a touch drag without the long press. */
@property({ attribute: "handle-selector" }) public handleSelector?: string;
private _sortable?: SortableInstance;
private _sortPlaceholder?: Comment;
connectedCallback(): void {
super.connectedCallback();
// Prevent the tab group from consuming Alt+Arrow and Cmd+Arrow keys,
// which browsers use for back/forward navigation.
this.addEventListener("keydown", this._handleKeyDown, true);
if (this.hasUpdated && this.sortable) {
this._createSortable();
}
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener("keydown", this._handleKeyDown, true);
this._destroySortable();
}
protected override updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (changedProperties.has("sortable")) {
// Dragging the tab bar to scroll would compete with dragging a tab.
this._dragScrollController.enabled = !this.sortable;
if (this.sortable) {
this._createSortable();
} else {
this._destroySortable();
}
}
}
private async _createSortable() {
if (this._sortable) {
return;
}
// eslint-disable-next-line @typescript-eslint/naming-convention
const Sortable = (await import("../resources/sortable")).default;
if (this._sortable || !this.sortable || !this.isConnected) {
return;
}
// supportPointer is missing from the SortableJS typings.
const options: SortableInstance.Options & { supportPointer?: boolean } = {
draggable: this.tabTag,
// SortableJS guesses vertical from the tabs being display: block.
direction:
this.placement === "start" || this.placement === "end"
? "vertical"
: "horizontal",
// The tab bar scrolls inside the shadow root, where SortableJS can't find it.
scroll: this.nav,
// Force the autoscroll fallback because it works better than the native one
forceAutoScrollFallback: true,
scrollSensitivity: AUTO_SCROLL_SENSITIVITY,
scrollSpeed: 20,
animation: 150,
// Keep swiping on touch screens for scrolling the tab bar.
delay: TOUCH_DRAG_DELAY_MS,
delayOnTouchOnly: true,
// A scrolling swipe ends in pointercancel, after which SortableJS
// ignores the next drag. Touch events end with touchend instead.
supportPointer: false,
onStart: this._handleSortableStart,
onEnd: this._handleSortableEnd,
onUpdate: this._handleSortableUpdate,
};
this._sortable = new Sortable(this, options);
// Capture, so the delay is set before SortableJS reads it.
this.addEventListener("touchstart", this._handleTouchStart, {
capture: true,
passive: true,
});
}
private _destroySortable() {
this._sortable?.destroy();
this._sortable = undefined;
this.removeEventListener("touchstart", this._handleTouchStart, true);
this._removeRtlAutoScrollListeners();
}
private _handleTouchStart = (ev: TouchEvent) => {
const onHandle =
!!this.handleSelector &&
ev
.composedPath()
.some(
(el) => el instanceof Element && el.matches(this.handleSelector!)
);
this._sortable!.option("delay", onHandle ? 0 : TOUCH_DRAG_DELAY_MS);
};
private _handleSortableStart = (evt: SortableEvent) => {
// Put the tab back after dropping, the owner renders the new order.
this._sortPlaceholder = document.createComment("sort-placeholder");
evt.item.after(this._sortPlaceholder);
// Touch drags add a copy of the tab to this element, which the tab group
// would show and select as an extra tab. The dimmed tab shows the position.
this.querySelector(":scope > .sortable-fallback")?.remove();
if (getComputedStyle(this.nav).direction !== "rtl") {
return;
}
// Capture, so this runs before SortableJS checks for auto scroll.
const options = { capture: true, passive: true };
window.addEventListener("dragover", this._unblockRtlAutoScroll, options);
window.addEventListener("touchmove", this._unblockRtlAutoScroll, options);
};
private _removeRtlAutoScrollListeners() {
window.removeEventListener("dragover", this._unblockRtlAutoScroll, true);
window.removeEventListener("touchmove", this._unblockRtlAutoScroll, true);
}
// SortableJS only auto scrolls left while scrollLeft isn't 0, but in RTL the
// tab bar starts at 0 and scrolls left into negative values.
private _unblockRtlAutoScroll = (ev: DragEvent | TouchEvent) => {
const x = "touches" in ev ? ev.touches[0]?.clientX : ev.clientX;
if (x === undefined || this.nav.scrollLeft !== 0) {
return;
}
const left = this.nav.getBoundingClientRect().left;
if (Math.abs(x - left) <= AUTO_SCROLL_SENSITIVITY) {
this.nav.scrollLeft = -1;
}
};
private _handleSortableEnd = (evt: SortableEvent) => {
this._removeRtlAutoScrollListeners();
this._sortPlaceholder?.replaceWith(evt.item);
this._sortPlaceholder = undefined;
};
private _handleSortableUpdate = (evt: SortableEvent) => {
fireEvent(this, "item-moved", {
oldIndex: evt.oldIndex!,
newIndex: evt.newIndex!,
});
};
private _handleKeyDown = (event: KeyboardEvent) => {
// The tab group cancels Enter and Space, so buttons inside a tab would not
// be pressed.
if (
(event.key === "Enter" || event.key === " ") &&
event.composedPath()[0] instanceof HTMLButtonElement &&
(event.target as HTMLElement).closest(this.tabTag)
) {
event.stopPropagation();
return;
}
if (event.altKey || event.metaKey) {
event.stopPropagation();
}
if (
this.sortable &&
event.altKey &&
!(event.ctrlKey || event.metaKey) &&
!event.shiftKey &&
(event.key === "ArrowUp" || event.key === "ArrowDown")
) {
this._moveTabWithKeyboard(event);
}
};
// Up and down follow the tab order, so they work the same in RTL.
private _moveTabWithKeyboard(event: KeyboardEvent) {
const tab = (event.target as HTMLElement).closest(this.tabTag);
if (!tab) {
return;
}
const tabs = [...this.children].filter(
(el) => el.localName === this.tabTag
);
const oldIndex = tabs.indexOf(tab);
const newIndex = oldIndex + (event.key === "ArrowUp" ? -1 : 1);
event.preventDefault();
if (oldIndex === -1 || newIndex < 0 || newIndex >= tabs.length) {
return;
}
fireEvent(this, "item-moved", { oldIndex, newIndex });
}
protected override handleClick(event: MouseEvent) {
if (this._dragScrollController.scrolled) {
return;
@@ -249,10 +67,6 @@ export class HaTabGroup extends TabGroup {
margin-inline-start: calc(-1 * var(--track-width));
}
::slotted(.sortable-ghost) {
opacity: 0.4;
}
.scroll-button::part(base):hover {
background-color: transparent;
}
+13 -24
View File
@@ -45,7 +45,7 @@ import {
distanceMeters,
} from "../../common/map/map-engine";
import type { MapStyleConfig } from "../../common/map/map-styles";
import { isDarkMapStyle, resolveMapStyle } from "../../common/map/map-styles";
import { resolveMapStyle } from "../../common/map/map-styles";
import { readMapThemeColors } from "../../common/map/map-theme-colors";
import { editableCircleStyles } from "../../common/map/editable-circle";
import { entityMapColor, zoneColor } from "../../common/map/entity-map-colors";
@@ -618,15 +618,15 @@ export class HaMap extends ReactiveElement {
});
}
if (changedProps.has("mapStyle")) {
this._engine?.setMapStyle(this._resolvedMapStyle);
}
const oldUi = changedProps.get("_ui") as HomeAssistantUI | undefined;
if (
!changedProps.has("themeMode") &&
(!changedProps.has("_ui") || (oldUi && oldUi.themes === this._ui.themes))
) {
// A style can have one half only, so the chrome follows it too
if (changedProps.has("mapStyle")) {
this._updateMapAppearance();
}
return;
}
@@ -647,11 +647,6 @@ export class HaMap extends ReactiveElement {
);
}
// What the map draws, which a style with one half decides over the theme mode
private get _styleDark() {
return isDarkMapStyle(this.mapStyle, this._darkMode);
}
// Memoized: it is read on every appearance sync, and the engines compare
// what they are handed against what they applied.
private _resolveMapStyle = memoizeOne(resolveMapStyle);
@@ -664,12 +659,12 @@ export class HaMap extends ReactiveElement {
// arguments, and a fresh object per read would defeat it.
private _readThemeColors(): void {
// Only one set of --ha-color-map-* is live on the page, the one for the
// mode the page is in. A card forced to the other mode, or a style with
// only the other half, draws the other palette, so those values would be
// the wrong half of the theme; the style's own colors are the better answer.
// mode the page is in. A card forced to the other mode draws the other
// palette, so those values would be the wrong half of the theme; the
// style's own colors are the better answer there.
const pageDark = Boolean(this._ui?.themes?.darkMode);
const colors =
this._styleDark === pageDark ? readMapThemeColors(this) : undefined;
this._darkMode === pageDark ? readMapThemeColors(this) : undefined;
if (!deepEqual(colors, this._themeColors)) {
this._themeColors = colors;
}
@@ -695,19 +690,13 @@ export class HaMap extends ReactiveElement {
return;
}
map.classList.toggle("clickable", this.clickable);
map.classList.toggle("dark", this._styleDark);
map.classList.toggle("dark", this._darkMode);
map.classList.toggle("drawn", this._mapDrawn);
// The sky belongs behind a drawn globe; on a blank canvas it is just a
// gradient with a glow in it
map.classList.toggle("space", this._vectorEngine && this._mapDrawn);
map.classList.toggle(
"forced-dark",
this._styleDark && (this.themeMode === "dark" || !this._darkMode)
);
map.classList.toggle(
"forced-light",
!this._styleDark && (this.themeMode === "light" || this._darkMode)
);
map.classList.toggle("forced-dark", this.themeMode === "dark");
map.classList.toggle("forced-light", this.themeMode === "light");
this._engine?.setMapStyle(this._resolvedMapStyle);
}
@@ -776,7 +765,7 @@ export class HaMap extends ReactiveElement {
map.id = "map";
// Which ground shows in the gap before the first frame; the rest of the
// classes wait for the engine (_updateMapAppearance)
map.classList.toggle("dark", this._styleDark);
map.classList.toggle("dark", this._darkMode);
this.shadowRoot!.append(map);
this._loading = true;
this._mapDrawn = false;
@@ -1,5 +1,4 @@
import {
mdiChartLine,
mdiClose,
mdiDevices,
mdiHome,
@@ -58,11 +57,6 @@ import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { FloorRegistryEntry } from "../../data/floor_registry";
import { domainToName } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import {
getStatisticMetadata,
isExternalStatistic,
type StatisticsMetaData,
} from "../../data/recorder";
import {
deviceMeetsFilter,
extractFromTarget,
@@ -183,9 +177,6 @@ export class HaTargetPickerItemRow extends LitElement {
@state() private _entries?: ExtractFromTargetResult;
/** Metadata of an external statistic picked as entity, which has no state. */
@state() private _statistic?: StatisticsMetaData;
private _fetchedEntries?: ExtractFromTargetResult;
private _lastFetch = 0;
@@ -782,19 +773,6 @@ export class HaTargetPickerItemRow extends LitElement {
this._fetchedEntries = undefined;
if (this.type === "entity") {
this._entries = undefined;
this._statistic = undefined;
if (isExternalStatistic(this.itemId)) {
try {
const [metadata] = await getStatisticMetadata(this._api.callWS, [
this.itemId,
]);
if (requestId === this._lastFetch) {
this._statistic = metadata;
}
} catch {
// Without metadata the statistic is shown as not found
}
}
return;
}
try {
@@ -881,15 +859,6 @@ export class HaTargetPickerItemRow extends LitElement {
}
if (type === "entity") {
const stateObject: HassEntity | undefined = this._states?.[item];
// External statistics have no state, only the metadata the recorder keeps.
if (!stateObject && this._statistic?.statistic_id === item) {
return {
name: this._statistic.name || item,
context: domainToName(this._i18n.localize, this._statistic.source),
fallbackIconPath: mdiChartLine,
notFound: false,
};
}
const { primary, secondary } = stateObject
? computeEntityPickerDisplay(
{
+1 -21
View File
@@ -48,8 +48,6 @@ class HaUserPicker extends LitElement {
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state() private _loadFailed = false;
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
if (!this.users) {
@@ -58,18 +56,7 @@ class HaUserPicker extends LitElement {
}
private async _fetchUsers() {
this._loadFailed = false;
try {
this.users = await fetchUsers(this._api.callWS);
} catch {
this._loadFailed = true;
}
}
private _retryFetchUsers(ev: Event) {
ev.stopPropagation();
this._fetchUsers();
this.users = await fetchUsers(this._api.callWS);
}
private usersMap = memoizeOne((users?: User[]): Map<string, User> => {
@@ -155,16 +142,9 @@ class HaUserPicker extends LitElement {
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
.rowRenderer=${this._rowRenderer}
.loading=${!this._loadFailed && this.users === undefined}
.loadError=${
this._loadFailed
? this._localize("ui.components.user-picker.load_failed")
: undefined
}
.searchKeys=${SEARCH_KEYS}
.unknownItemText=${this._localize("ui.components.user-picker.unknown")}
@value-changed=${this._valueChanged}
@picker-retry=${this._retryFetchUsers}
>
</ha-generic-picker>
`;
+8 -12
View File
@@ -1,5 +1,5 @@
import type { Connection } from "home-assistant-js-websocket";
import { getCollection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
import type { Store } from "home-assistant-js-websocket/dist/store";
import { stringCompare } from "../common/string/compare";
import type { HomeAssistant } from "../types";
@@ -27,12 +27,12 @@ export const fetchCategoryRegistry = (conn: Connection, scope: string) =>
categories.sort((ent1, ent2) => stringCompare(ent1.name, ent2.name))
);
export const getCategoryRegistryCollection = (
export const subscribeCategoryRegistry = (
conn: Connection,
scope: string
scope: string,
onChange: (floors: CategoryRegistryEntry[]) => void
) =>
getCollection<CategoryRegistryEntry[]>(
conn,
createCollection<CategoryRegistryEntry[]>(
`_categoryRegistry_${scope}`,
(conn2: Connection) => fetchCategoryRegistry(conn2, scope),
(conn2: Connection, store: Store<CategoryRegistryEntry[]>) =>
@@ -47,15 +47,11 @@ export const getCategoryRegistryCollection = (
true
),
"category_registry_updated"
)
),
conn,
onChange
);
export const subscribeCategoryRegistry = (
conn: Connection,
scope: string,
onChange: (floors: CategoryRegistryEntry[]) => void
) => getCategoryRegistryCollection(conn, scope).subscribe(onChange);
export const createCategoryRegistryEntry = (
callWS: HomeAssistant["callWS"],
scope: string,
+1 -60
View File
@@ -422,7 +422,6 @@ export interface EnergyData {
startCompare?: Date;
endCompare?: Date;
compareMode?: CompareMode;
combineSources?: boolean;
prefs: EnergyPreferences;
info: EnergyInfo;
stats: Statistics;
@@ -946,13 +945,10 @@ export interface EnergyCollection extends Omit<
start: Date;
end?: Date;
compare?: CompareMode;
combineSources?: boolean;
prefs?: EnergyPreferences;
clearPrefs(): void;
setPeriod(newStart: Date, newEnd?: Date): void;
setCompare(compare: CompareMode): void;
setCombineSources(combine: boolean): void;
setDefaultCombineSources(combine: boolean): void;
isActive(): boolean;
_refreshTimeout?: number;
_updatePeriodTimeout?: number;
@@ -1040,24 +1036,6 @@ export const getEnergyDefaultPeriodStorageKey = (
return `energy-default-period-${key}`;
};
export const getEnergyCombineSourcesStorageKey = (
panelUrl: string,
collectionKey?: string
): string => {
const [key] = convertCollectionKeyToConnection(panelUrl, collectionKey);
return `energy-combine-sources-${key}`;
};
export const hasCombinableEnergySources = (
prefs: EnergyPreferences,
types?: EnergySource["type"][]
): boolean => {
const sources = types
? prefs.energy_sources.filter((source) => types.includes(source.type))
: prefs.energy_sources;
return new Set(sources.map((source) => source.type)).size < sources.length;
};
// When today's first hourly statistic becomes available (01:00 in the
// configured timezone). Rolling the statistics view over at midnight would
// show an empty graph.
@@ -1202,11 +1180,6 @@ export const getEnergyDataCollection = (
: preferredPeriod;
const [initialStart, initialEnd] = calcDateRange(locale, config, period);
const combineSourcesStorageKey = getEnergyCombineSourcesStorageKey(
panelUrl,
options.key
);
const storedCombineSources = localStorage.getItem(combineSourcesStorageKey);
// True while tracking today (or hour-0 yesterday), rather than a custom range.
let followLiveDay = preferredPeriod === "today";
@@ -1261,7 +1234,7 @@ export const getEnergyDataCollection = (
return;
}
publishedSelectionRevision = requestSelectionRevision;
store.setState(withCombineSources(data), true);
store.setState(data, true);
};
// Use the collection's subscription lifecycle, but publish explicitly so an
@@ -1381,23 +1354,6 @@ export const getEnergyDataCollection = (
};
};
// The setting only applies while a source type is configured twice, so it
// cannot leave cards split once there is nothing left to combine.
const withCombineSources = (data: EnergyData): EnergyData => ({
...data,
combineSources: hasCombinableEnergySources(data.prefs)
? collection.combineSources
: undefined,
});
// Display-only: re-publish the current data instead of fetching it again.
const applyCombineSources = (combine: boolean) => {
collection.combineSources = combine;
if (store.state) {
store.setState(withCombineSources(store.state), true);
}
};
const collection: EnergyCollection = {
get state() {
return store.state;
@@ -1406,12 +1362,6 @@ export const getEnergyDataCollection = (
subscribe,
start: calcDate(initialStart, startOfDay, locale, config),
end: calcDate(initialEnd, endOfDay, locale, config),
combineSources:
storedCombineSources === "true"
? true
: storedCombineSources === "false"
? false
: undefined,
prefs: options.prefs,
_active: 0,
isActive: () => !!collection._active,
@@ -1448,15 +1398,6 @@ export const getEnergyDataCollection = (
collection.compare = compare;
}
},
setCombineSources(combine: boolean) {
applyCombineSources(combine);
localStorage.setItem(combineSourcesStorageKey, String(combine));
},
setDefaultCombineSources(combine: boolean) {
if (collection.combineSources === undefined) {
applyCombineSources(combine);
}
},
};
// Custom cards also access the Energy collection directly on the connection.
(connection as any)[key] = collection;
+3 -30
View File
@@ -13,11 +13,7 @@ import { computeRTL } from "../common/util/compute_rtl";
import type { HomeAssistant } from "../types";
import { isNumericSensorDeviceClass } from "./sensor";
import type { FrontendLocaleData } from "./translation";
import {
isExternalStatistic,
type Statistics,
type StatisticsMetaData,
} from "./recorder";
import type { Statistics } from "./recorder";
const DOMAINS_USE_LAST_UPDATED = ["climate", "humidifier", "water_heater"];
const NEED_ATTRIBUTE_DOMAINS = [
@@ -521,29 +517,11 @@ const isNumericFromAttributes = (attributes: Record<string, any>) =>
const BLANK_UNIT = " ";
/**
* Attributes for an external statistic, which has no entity. Without them the
* history names the series after its ID and shows it without a unit.
*/
const statisticAttributes = (
metadata: StatisticsMetaData | undefined
): Record<string, string> => {
const attributes: Record<string, string> = {};
if (metadata?.name) {
attributes.friendly_name = metadata.name;
}
if (metadata?.statistics_unit_of_measurement) {
attributes.unit_of_measurement = metadata.statistics_unit_of_measurement;
}
return attributes;
};
export const convertStatisticsToHistory = (
hass: HomeAssistant,
statistics: Statistics,
statisticIds: string[],
splitDeviceClasses = false,
statisticsMetadata?: Record<string, StatisticsMetaData>
splitDeviceClasses = false
): HistoryResult => {
// Maintain the statistic id ordering
const orderedStatistics: Statistics = {};
@@ -556,15 +534,10 @@ export const convertStatisticsToHistory = (
// Convert statistics to HistoryResult format
const statsHistoryStates: HistoryStates = {};
Object.entries(orderedStatistics).forEach(([key, value]) => {
// Other statistics keep a blank unit, so that they merge into the history
// line of their entity even after the entity was removed.
const attributes = isExternalStatistic(key)
? statisticAttributes(statisticsMetadata?.[key])
: {};
const entityHistoryStates: EntityHistoryState[] = value.map((e) => ({
s: e.mean != null ? e.mean.toString() : e.state!.toString(),
lc: e.end / 1000,
a: attributes,
a: {},
lu: e.end / 1000,
}));
statsHistoryStates[key] = entityHistoryStates;
+13
View File
@@ -34,6 +34,7 @@ export interface RepositoryBase {
export interface RepositoryInfo extends RepositoryBase {
additional_info: string;
beta: boolean;
default_branch: string;
issues: number;
releases: string[];
@@ -98,6 +99,18 @@ export const uninstallMarketplaceRepository = (
repository: repositoryId,
});
// The latest version and the updates include pre-releases when on
export const setMarketplaceRepositoryBeta = (
hass: CallWS,
repositoryId: string,
showBeta: boolean
) =>
hass.callWS<null>({
type: "marketplace/repository/beta",
repository: repositoryId,
show_beta: showBeta,
});
// Fetches the information of the repository from GitHub again
export const refreshMarketplaceRepository = (
hass: CallWS,
-1
View File
@@ -469,7 +469,6 @@ export interface ObjectSelector {
translation_key?: string;
fields?: Record<string, ObjectSelectorField>;
multiple?: boolean;
reorder?: boolean;
} | null;
}
+1 -24
View File
@@ -1,16 +1,12 @@
import type { HassEntities } from "home-assistant-js-websocket";
import { ensureArray } from "../../common/array/ensure-array";
import { THEME_COLORS } from "../../common/color/compute-color";
import { createDurationData } from "../../common/datetime/create_duration_data";
import { formatDurationLong } from "../../common/datetime/format_duration";
import { normalizeDuration } from "../../common/datetime/normalize_duration";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { DEFAULT_ENTITY_NAME } from "../../common/entity/compute_entity_name_display";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../../common/translations/localize";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { HomeAssistantFormatters } from "../../types";
import type { AreaRegistryEntry } from "../area/area_registry";
import type { DeviceRegistryEntry } from "../device/device_registry";
@@ -154,25 +150,6 @@ export const formatSelectorValue = (
.join(", ");
}
if ("ui_color" in selector) {
const color = String(value);
const extraOption = selector.ui_color?.extra_options?.find(
(option) => option.value === color
);
if (extraOption) {
return extraOption.label;
}
if (color === "none" || color === "state") {
return options.localize(`ui.components.color-picker.${color}`);
}
if (THEME_COLORS.has(color)) {
return options.localize(
`ui.components.color-picker.colors.${color}` as LocalizeKeys
);
}
return color;
}
if ("duration" in selector) {
const data = createDurationData(value);
if (!data) {
-8
View File
@@ -3,14 +3,6 @@ import type { HomeAssistant } from "../types";
export type SystemLogLevel =
"critical" | "error" | "warning" | "info" | "debug";
// The levels Core's system log keeps, most severe first. It drops anything
// below warning.
export const SYSTEM_LOG_LEVELS = [
"critical",
"error",
"warning",
] as const satisfies readonly SystemLogLevel[];
export interface LoggedError {
name: string;
message: [string, ...string[]];
+13 -22
View File
@@ -3,8 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-icon-next";
import "../../components/item/ha-list-item-button";
import "../../components/list/ha-list-base";
import "../../components/ha-list-item";
import type { DataEntryFlowStepMenu } from "../../data/data_entry_flow";
import type { HomeAssistant } from "../../types";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
@@ -80,30 +79,28 @@ class StepFlowMenu extends LitElement {
return html`
${description ? html`<div class="content">${description}</div>` : nothing}
<ha-list-base
class="options"
aria-label=${this.flowConfig.renderMenuHeader(this.hass, this.step)}
>
<div class="options">
${options.map((option) => {
const optionDescription = optionDescriptions[option];
return html`
<ha-list-item-button
.headline=${translations[option]}
<ha-list-item
hasMeta
.step=${option}
@click=${this._handleStep}
?twoline=${optionDescription}
?multiline-secondary=${optionDescription}
>
<span>${translations[option]}</span>
${
optionDescription
? html`<span slot="supporting-text" class="description"
>${optionDescription}</span
>`
? html`<span slot="secondary"> ${optionDescription} </span>`
: nothing
}
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`;
})}
</ha-list-base>
</div>
`;
}
@@ -132,14 +129,8 @@ class StepFlowMenu extends LitElement {
.content + .options {
margin-top: var(--ha-space-2);
}
ha-list-item-button {
--ha-row-item-padding-inline: var(--ha-space-6);
}
.description {
white-space: normal;
}
ha-icon-next {
color: var(--secondary-text-color);
ha-list-item {
--mdc-list-side-padding: var(--ha-space-6);
}
`,
];
+1 -11
View File
@@ -122,13 +122,7 @@ export class MockBaseEntity {
this.attributes = { ...this.attributes, ...changes.attributes };
}
// A removed or replaced entity can finish its transitions, so that their
// timers stop, but must not bring its state back
if (this.hass?.mockEntities[this.entityId] !== this) {
return;
}
this.hass.updateStates({
this.hass!.updateStates({
[this.entityId]: this.toState(),
});
}
@@ -165,11 +159,7 @@ export class MockBaseEntity {
return {
entity_id: this.entityId,
state: this.state,
// Every attribute of the input, like Core keeps the attributes of an
// integration. What the mock works out itself wins, like a light that
// is off having no brightness.
attributes: {
...this.attributes,
...this._getBaseAttributes(),
...this._getCapabilityAttributes(),
...this._getStateAttributes(),
-12
View File
@@ -1,12 +0,0 @@
import { MockBaseEntity } from "./base-entity";
import type { EntityAttributes } from "./types";
/**
* Entity for domains without their own mock, such as sensor, weather and
* calendar. Keeps all its attributes, as nothing here computes them.
*/
export class MockGenericEntity extends MockBaseEntity {
protected _getStateAttributes(): EntityAttributes {
return this.attributes;
}
}
+2 -4
View File
@@ -1,11 +1,10 @@
import type { MockBaseEntity } from "./base-entity";
import { MockBaseEntity } from "./base-entity";
import type { EntityInput } from "./types";
import { MockAlarmControlPanelEntity } from "./alarm-control-panel-entity";
import { MockAutomationEntity } from "./automation-entity";
import { MockClimateEntity } from "./climate-entity";
import { MockCoverEntity } from "./cover-entity";
import { MockFanEntity } from "./fan-entity";
import { MockGenericEntity } from "./generic-entity";
import { MockGroupEntity } from "./group-entity";
import { MockHumidifierEntity } from "./humidifier-entity";
import { MockInputNumberEntity } from "./input-number-entity";
@@ -50,6 +49,5 @@ const TYPES: Record<string, EntityConstructor> = {
export const getEntity = (input: EntityInput): MockBaseEntity => {
const [domain] = input.entity_id.split(".", 2);
return new (TYPES[domain] || MockGenericEntity)(input);
return new (TYPES[domain] || MockBaseEntity)(input);
};
+14 -65
View File
@@ -5,7 +5,6 @@ import {
invalidateThemeCache,
} from "../common/dom/apply_themes_on_element";
import { fireEvent } from "../common/dom/fire_event";
import { computeDeviceName } from "../common/entity/compute_device_name";
import { computeEntityNameDisplayWithoutContext } from "../common/entity/compute_entity_name_display";
import { computeFormatFunctions } from "../common/translations/entity-state";
import { computeLocalize } from "../common/translations/localize";
@@ -47,7 +46,6 @@ import { demoPanels } from "./demo_panels";
import { demoServices } from "./demo_services";
import { ENTITY_COMPONENT_ICONS } from "./entity_component_icons";
import { getEntity } from "./entities/registry";
import type { MockBaseEntity } from "./entities/base-entity";
import type { EntityInput } from "./entities/types";
const ensureArray = <T>(val: T | T[]): T[] =>
@@ -77,8 +75,6 @@ export interface MockHomeAssistant extends HomeAssistant {
updateHass(obj: Partial<MockHomeAssistant>);
updateStates(newStates: HassEntities);
addEntities(entities: EntityInput | EntityInput[], replace?: boolean);
// Recomputes the format functions, such as after the areas change.
updateFormatFunctions(): Promise<void>;
updateTranslations(fragment: null | string, language?: string);
addTranslations(translations: Record<string, string>, language?: string);
mockWS<T extends (...args) => any = any>(
@@ -301,77 +297,34 @@ export const provideHass = (
});
}
// Like Core, an entity of a device has only its own name, which follows the
// name of the device in its friendly name. An entity named like its device
// has no name of its own.
const registryName = (
ent: MockBaseEntity
): Pick<EntityRegistryDisplayEntry, "name" | "has_entity_name"> => {
const friendlyName: string | undefined =
ent.attributes.friendly_name || undefined;
const device = ent.deviceId ? hass().devices[ent.deviceId] : undefined;
const deviceName = device ? computeDeviceName(device) : undefined;
if (
!deviceName ||
!friendlyName ||
(friendlyName !== deviceName &&
!friendlyName.startsWith(`${deviceName} `))
) {
return { name: friendlyName };
}
return {
name: friendlyName.slice(deviceName.length).trim() || undefined,
has_entity_name: true,
};
};
function addEntities(
newEntities: EntityInput | EntityInput[],
replace = false
) {
if (replace) {
// Drop the old mock entities too, so that services cannot bring them back.
for (const entityId of Object.keys(entities)) {
delete entities[entityId];
}
}
const states = {};
// A new object, so that registry consumers see the change
const registry: HomeAssistant["entities"] = replace
? {}
: { ...hass().entities };
ensureArray(newEntities).forEach((input) => {
const ent = getEntity(input);
ent.hass = hass();
entities[ent.entityId] = ent;
states[ent.entityId] = ent.toState();
const name = registryName(ent);
registry[ent.entityId] = {
hass().entities[ent.entityId] = {
entity_id: ent.entityId,
...name,
name: ent.attributes.friendly_name || undefined,
icon: undefined,
platform: ent.platform ?? "demo",
labels: [],
area_id: ent.areaId,
device_id: ent.deviceId,
// Only follow the device when its name was split off, so a friendly
// name that doesn't start with it is shown as it is
next_name_part: ent.areaId
? "area"
: name.has_entity_name
? "device"
: undefined,
} satisfies EntityRegistryDisplayEntry;
});
hass().updateHass({
entities: registry,
states: replace ? states : { ...hass().states, ...states },
});
if (replace) {
hass().updateHass({
states,
});
} else {
updateStates(states);
}
updateFormatFunctions();
}
@@ -394,18 +347,15 @@ export const provideHass = (
const localLanguage = getLocalLanguage();
const noop = () => undefined;
const auth = {
data: {
hassUrl: location.origin,
},
} as any;
const hassObj: MockHomeAssistant = {
// Home Assistant properties
auth,
auth: {
data: {
hassUrl: location.origin,
},
} as any,
connection: {
// The map reads the URL of Home Assistant from here
options: { auth },
addEventListener: noop,
removeEventListener: noop,
sendMessage: (msg) => {
@@ -590,7 +540,6 @@ export const provideHass = (
return hass().localize;
},
addEntities,
updateFormatFunctions,
mockWS(type, callback) {
wsCommands[type] = callback;
},
+1 -1
View File
@@ -175,7 +175,7 @@ class OnboardingCoreConfig extends LitElement {
suggested = lang;
}
}
this._country ??= suggested;
this._country = suggested;
fireEvent(this, "onboarding-progress", { increase: 0.5 });
await this.updateComplete;
+4 -13
View File
@@ -306,7 +306,7 @@ class OnboardingLocation extends LitElement {
(plc) => plc.place_id === Number(ev.detail.id)
)!;
this._location = [Number(place.lat), Number(place.lon)];
this._setCountry(place.address.country_code);
this._country = place.address.country_code.toUpperCase();
}
private _itemClicked(ev) {
@@ -315,7 +315,7 @@ class OnboardingLocation extends LitElement {
(plc) => plc.place_id === ev.currentTarget.placeId
)!;
this._location = [Number(place.lat), Number(place.lon)];
this._setCountry(place.address.country_code);
this._country = place.address.country_code.toUpperCase();
}
private async _addressSearch(ev: KeyboardEvent) {
@@ -353,8 +353,7 @@ class OnboardingLocation extends LitElement {
Number(this._places[0].lat),
Number(this._places[0].lon),
];
this._setCountry(this._places[0].address.country_code);
this._elevation = undefined;
this._country = this._places[0].address.country_code.toUpperCase();
}
} catch (e: any) {
this._places = undefined;
@@ -370,19 +369,11 @@ class OnboardingLocation extends LitElement {
}
this._places = null;
const reverse = await reverseGeocode(this._location, this.hass);
this._setCountry(reverse.address.country_code);
this._country = reverse.address.country_code.toUpperCase();
this._places = [reverse];
this._highlightedMarker = reverse.place_id;
}
private _setCountry(countryCode: string) {
this._country = countryCode.toUpperCase();
// Detected values belong to the detected location
this._unitSystem = undefined;
this._currency = undefined;
this._timeZone = undefined;
}
private async _handleButtonClick(ev) {
if (this._search) {
this._searchAddress(ev.target.parentElement.value);
@@ -101,8 +101,8 @@ class SupervisorAppConfigDashboard extends LitElement {
css`
.content {
margin: auto;
padding: var(--ha-space-2) var(--ha-content-gutter);
max-width: var(--ha-content-width-narrow);
padding: var(--ha-space-2);
max-width: 1024px;
}
supervisor-app-network,
supervisor-app-audio,
@@ -65,8 +65,8 @@ class SupervisorAppDocumentationDashboard extends LitElement {
}
.content {
margin: auto;
padding: var(--ha-space-2) var(--ha-content-gutter);
max-width: var(--ha-content-width-narrow);
padding: var(--ha-space-2);
max-width: 1024px;
direction: ltr;
}
ha-markdown {
@@ -45,8 +45,8 @@ class SupervisorAppInfoDashboard extends LitElement {
css`
.content {
margin: auto;
padding: var(--ha-space-4) var(--ha-content-gutter);
max-width: var(--ha-content-width-wide);
padding: var(--ha-space-4);
max-width: 1200px;
}
`,
];
@@ -1593,6 +1593,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
.app {
display: flex;
gap: var(--ha-space-4);
max-width: 1200px;
}
.app.column {
flex-direction: column;
@@ -99,9 +99,9 @@ class HaConfigAppsInfo extends LitElement {
haStyle,
css`
.content {
max-width: var(--ha-content-width-narrow);
max-width: 600px;
margin: 0 auto;
padding: var(--ha-space-4) var(--ha-content-gutter);
padding: var(--ha-space-4);
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
@@ -980,8 +980,9 @@ class HaConfigAreaPage extends LitElement {
flex-wrap: wrap;
gap: var(--ha-space-4);
margin: auto;
max-width: var(--ha-content-width-wide);
padding: var(--ha-space-2) var(--ha-content-gutter);
max-width: 1280px;
box-sizing: border-box;
padding: var(--ha-space-2) var(--ha-space-4);
margin-top: var(--ha-space-8);
margin-bottom: var(--ha-space-8);
}
@@ -676,6 +676,7 @@ export class HaConfigAreasDashboard extends LitElement {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-gap: 16px 16px;
max-width: 2000px;
margin-bottom: 16px;
}
.areas > * {
@@ -37,7 +37,6 @@ import {
isDynamic,
} from "../../../data/automation";
import { getConditionDomain } from "../../../data/condition";
import type { ConditionDescriptions } from "../../../data/condition";
import {
apiContext,
conditionDescriptionsContext,
@@ -57,7 +56,6 @@ import {
getTriggerIcons,
} from "../../../data/icons";
import { domainToName } from "../../../data/integration";
import type { DomainManifestLookup } from "../../../data/integration";
import type { LabelRegistryEntry } from "../../../data/label/label_registry";
import {
TARGET_SEPARATOR,
@@ -65,7 +63,6 @@ import {
type SingleHassServiceTarget,
} from "../../../data/target";
import { getTriggerDomain } from "../../../data/trigger";
import type { TriggerDescriptions } from "../../../data/trigger";
import { DialogMixin } from "../../../dialogs/dialog-mixin";
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { haStyleScrollbar } from "../../../resources/styles";
@@ -157,29 +154,33 @@ class DialogAddAutomationElement extends DialogMixin<AddAutomationElementDialogP
@consume({ context: statesContext, subscribe: true })
@transform<HomeAssistant["states"], Set<string>>({
transformer: function (this: DialogAddAutomationElement, states) {
const entityIds = new Set(Object.keys(states));
const domains = new Set(Object.keys(states).map(computeDomain));
// keep the previous set, so a state change doesn't rerender the dialog
return deepEqual(entityIds, this._entityIds)
? this._entityIds
: entityIds;
return deepEqual(domains, this._usedDomains)
? this._usedDomains
: domains;
},
})
private _entityIds?: Set<string>;
private _usedDomains?: Set<string>;
private get _usedDomains(): Set<string> | undefined {
return this._computeUsedDomains(this._entityIds);
}
private get _systemDomains(): SystemDomains | undefined {
return this._calculateSystemDomains(
this.params?.type,
this._triggerDescriptions,
this._conditionDescriptions,
this._manifests,
this._registries,
this._entityIds
);
}
@state()
@consume({ context: statesContext, subscribe: true })
@transform<HomeAssistant["states"], SystemDomains | undefined>({
transformer: function (this: DialogAddAutomationElement, states) {
const systemDomains = this._calculateSystemDomains(states);
// keep the previous result, so a state change doesn't rerender the dialog
return deepEqual(systemDomains, this._systemDomains)
? this._systemDomains
: systemDomains;
},
watch: [
"_manifests",
"_triggerDescriptions",
"_conditionDescriptions",
"_registries",
],
})
private _systemDomains?: SystemDomains;
@state()
@consume({ context: servicesContext, subscribe: true })
@@ -883,43 +884,32 @@ class DialogAddAutomationElement extends DialogMixin<AddAutomationElementDialogP
})
);
private _computeUsedDomains = memoizeOne(
(entityIds: Set<string> | undefined) =>
entityIds && new Set(Array.from(entityIds, computeDomain))
);
private _calculateSystemDomains = memoizeOne(
(
type: AddAutomationElementDialogParams["type"] | undefined,
triggerDescriptions: TriggerDescriptions,
conditionDescriptions: ConditionDescriptions,
manifests: DomainManifestLookup | undefined,
registries: ContextType<typeof registriesContext> | undefined,
_entityIds: Set<string> | undefined
): SystemDomains | undefined => {
if (!type || type === "action" || !manifests || !registries) {
return undefined;
}
const { entities, devices } = registries;
return type === "trigger"
? calculateActiveSystemDomains(
triggerDescriptions,
manifests,
getTriggerDomain,
this._states,
entities,
devices
)
: calculateActiveSystemDomains(
conditionDescriptions,
manifests,
getConditionDomain,
this._states,
entities,
devices
);
private _calculateSystemDomains(
states: HomeAssistant["states"]
): SystemDomains | undefined {
const type = this.params?.type;
if (!type || type === "action" || !this._manifests || !this._registries) {
return undefined;
}
);
const { entities, devices } = this._registries;
return type === "trigger"
? calculateActiveSystemDomains(
this._triggerDescriptions,
this._manifests,
getTriggerDomain,
states,
entities,
devices
)
: calculateActiveSystemDomains(
this._conditionDescriptions,
this._manifests,
getConditionDomain,
states,
entities,
devices
);
}
private _getAllItems = memoizeOne(getAllItems);
@@ -106,7 +106,7 @@ class HaBackupDetailsRestore extends LitElement {
static styles = css`
:host {
max-width: var(--ha-content-width-narrow);
max-width: 690px;
width: 100%;
margin: 0 auto;
gap: var(--ha-space-6);
@@ -135,7 +135,7 @@ class HaBackupDetailsSummary extends LitElement {
static styles = css`
:host {
max-width: var(--ha-content-width-narrow);
max-width: 690px;
width: 100%;
margin: 0 auto;
gap: var(--ha-space-6);
@@ -111,8 +111,8 @@ class HaBackupOverviewBackups extends LitElement {
haStyle,
css`
.content {
padding: 28px var(--ha-content-gutter) 0;
max-width: var(--ha-content-width-narrow);
padding: 28px 20px 0;
max-width: 690px;
margin: 0 auto;
gap: var(--ha-space-6);
display: flex;
@@ -298,8 +298,8 @@ class HaBackupBackupsSummary extends LitElement {
haStyle,
css`
.content {
padding: 28px var(--ha-content-gutter) 0;
max-width: var(--ha-content-width-narrow);
padding: 28px 20px 0;
max-width: 690px;
margin: 0 auto;
gap: var(--ha-space-6);
display: flex;
@@ -134,8 +134,8 @@ class HaConfigBackupAppUpdateBackups extends LitElement {
}
.content {
padding: 28px var(--ha-content-gutter) 0;
max-width: var(--ha-content-width-narrow);
padding: 28px 20px 0;
max-width: 690px;
margin: 0 auto;
gap: var(--ha-space-6);
display: flex;
@@ -361,8 +361,8 @@ class HaConfigBackupDetails extends LitElement {
static styles = css`
.content {
padding: 28px var(--ha-content-gutter) 0;
max-width: var(--ha-content-width-narrow);
padding: 28px 20px 0;
max-width: 690px;
margin: 0 auto;
gap: var(--ha-space-6);
display: grid;
@@ -328,8 +328,8 @@ class HaConfigBackupDetails extends LitElement {
static styles = css`
.content {
padding: 28px var(--ha-content-gutter) 0;
max-width: var(--ha-content-width-narrow);
padding: 28px 20px 0;
max-width: 690px;
margin: 0 auto;
gap: var(--ha-space-6);
display: grid;
@@ -375,8 +375,8 @@ class HaConfigBackupOverview extends LitElement {
}
.content {
padding: 28px var(--ha-content-gutter) 0;
max-width: var(--ha-content-width-narrow);
padding: 28px 20px 0;
max-width: 690px;
margin: 0 auto;
gap: var(--ha-space-6);
display: flex;
Loaded 100 of 253 files, more files were not shown because too many files have changed in this diff. Show more