mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-07 21:45:16 +00:00
Compare commits
103
Commits
cropperjs-v2
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eebc57d89a | ||
|
|
63b474f43c | ||
|
|
d5cc973c22 | ||
|
|
00e76021ce | ||
|
|
fb88d66421 | ||
|
|
23b161d257 | ||
|
|
123a32dd81 | ||
|
|
4dc713abe6 | ||
|
|
b9182e3ea6 | ||
|
|
9bd57fa122 | ||
|
|
4af7503112 | ||
|
|
984e63dcd0 | ||
|
|
f90ed06080 | ||
|
|
b176b8226b | ||
|
|
3f3e9297f7 | ||
|
|
83028028e8 | ||
|
|
37d7c6144c | ||
|
|
f469f3eeae | ||
|
|
069e87c913 | ||
|
|
46bdbee8f3 | ||
|
|
68def911a6 | ||
|
|
a8fdb99317 | ||
|
|
3d1bf61683 | ||
|
|
5cfdff0e21 | ||
|
|
54cefeb816 | ||
|
|
4458205273 | ||
|
|
0c2de46f11 | ||
|
|
6bef900455 | ||
|
|
438bfa2710 | ||
|
|
96864e951a | ||
|
|
a1355e5116 | ||
|
|
ae4366020c | ||
|
|
7f73d29b2f | ||
|
|
ebf453ccc7 | ||
|
|
a0d9afe695 | ||
|
|
c42d048736 | ||
|
|
2f62ed5f73 | ||
|
|
8b6caeecdd | ||
|
|
4800b4f7e9 | ||
|
|
c7afc3391c | ||
|
|
3e9490e547 | ||
|
|
7a1cd6bbfe | ||
|
|
a2c3147d33 | ||
|
|
7039e3ad9f | ||
|
|
e97745d7d5 | ||
|
|
790c957405 | ||
|
|
f740091579 | ||
|
|
f76c88dd9f | ||
|
|
a428d61cdb | ||
|
|
fcf7eeae46 | ||
|
|
6d2817b7cf | ||
|
|
640e4e2f94 | ||
|
|
6670139c02 | ||
|
|
93eb44b041 | ||
|
|
ba6067f048 | ||
|
|
cd866a497a | ||
|
|
98a8483426 | ||
|
|
33af67bafe | ||
|
|
027d9578ec | ||
|
|
5fef8f3a68 | ||
|
|
18e65c1940 | ||
|
|
29906d6f83 | ||
|
|
952c227370 | ||
|
|
173c60e57b | ||
|
|
ef134330f3 | ||
|
|
c75386ab33 | ||
|
|
61b433f912 | ||
|
|
16ed504c66 | ||
|
|
e9550ffe52 | ||
|
|
2c6cf8e45b | ||
|
|
0dcfc247a7 | ||
|
|
2ab1f187c5 | ||
|
|
56d6f1c571 | ||
|
|
f169ae79f5 | ||
|
|
599089a2d3 | ||
|
|
2af451338f | ||
|
|
2ba9a1fc61 | ||
|
|
96e525f7a3 | ||
|
|
c42dac197f | ||
|
|
8fbae6346d | ||
|
|
2a69518dba | ||
|
|
960bf08c84 | ||
|
|
6c2bc65f07 | ||
|
|
e52547e374 | ||
|
|
7474e3e342 | ||
|
|
8dbd1527d9 | ||
|
|
d9d849c843 | ||
|
|
4f73b43912 | ||
|
|
a6fb28dfef | ||
|
|
d4ad00c509 | ||
|
|
2fdef6883c | ||
|
|
598034b1d3 | ||
|
|
4ad7c7a96a | ||
|
|
14f66ca105 | ||
|
|
831dbeb975 | ||
|
|
aecf17aae4 | ||
|
|
037d3dad01 | ||
|
|
e80c2e55b5 | ||
|
|
581b6abdd8 | ||
|
|
4c43c7446a | ||
|
|
0f73094afd | ||
|
|
53f8e6c8cb | ||
|
|
5c9fc8723c |
No files matched your search
@@ -10,6 +10,7 @@ Use this skill when editing CSS, layout, visual hierarchy, theme integration, re
|
||||
## Theme And Layout Basics
|
||||
|
||||
- Use Home Assistant CSS custom properties instead of hardcoded colors.
|
||||
- CSS in the static HTML templates under `src/html/` must give custom properties a fallback value (for example `var(--ha-space-4, 16px)`), because the global tokens only exist once the JS-appended global stylesheet from `append-ha-style` has loaded.
|
||||
- Use `--ha-space-*` spacing tokens instead of hardcoded spacing where possible.
|
||||
- Keep components mobile-first and enhance for desktop.
|
||||
- Keep layouts RTL-safe. Prefer logical properties when they fit.
|
||||
|
||||
@@ -12,3 +12,7 @@
|
||||
|
||||
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
|
||||
@@ -0,0 +1,6 @@
|
||||
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'
|
||||
@@ -16,6 +16,7 @@ runs:
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
packages/*/node_modules
|
||||
~/.local/share/pnpm/store
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
@@ -24,7 +25,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
|
||||
|
||||
@@ -35,6 +36,7 @@ runs:
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
packages/*/node_modules
|
||||
~/.local/share/pnpm/store
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
|
||||
@@ -28,6 +28,7 @@ runs:
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
packages/*/node_modules
|
||||
~/.local/share/pnpm/store
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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
|
||||
@@ -0,0 +1,190 @@
|
||||
# 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
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
deploy_dev:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy Development
|
||||
if: github.repository == 'home-assistant/frontend' && github.event_name != 'push'
|
||||
if: github.repository == 'home-assistant/frontend' && github.ref_name != 'master'
|
||||
environment:
|
||||
name: Cast Development
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
@@ -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
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
deploy_master:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy Production
|
||||
if: github.repository == 'home-assistant/frontend' && github.event_name == 'push'
|
||||
if: github.repository == 'home-assistant/frontend' && github.ref_name == 'master'
|
||||
environment:
|
||||
name: Cast Production
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -22,10 +22,45 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
# 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
|
||||
with:
|
||||
node-modules-cache: true
|
||||
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
|
||||
|
||||
- name: Skip fetching nightly translations
|
||||
run: echo "SKIP_FETCH_NIGHTLY_TRANSLATIONS=1" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
deploy_dev:
|
||||
runs-on: ubuntu-latest
|
||||
name: Demo Development
|
||||
if: github.repository == 'home-assistant/frontend' && (github.event_name != 'push' || github.ref_name != 'master')
|
||||
if: github.repository == 'home-assistant/frontend' && github.ref_name != 'master'
|
||||
environment:
|
||||
name: Demo Development
|
||||
url: ${{ steps.deploy.outputs.unique_deploy_url }}
|
||||
@@ -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 }}
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
deploy_master:
|
||||
runs-on: ubuntu-latest
|
||||
name: Demo Production
|
||||
if: github.repository == 'home-assistant/frontend' && github.event_name == 'push' && github.ref_name == 'master'
|
||||
if: github.repository == 'home-assistant/frontend' && github.ref_name == 'master'
|
||||
environment:
|
||||
name: Demo Production
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
|
||||
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
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
|
||||
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
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
|
||||
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
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -284,6 +284,7 @@ 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),
|
||||
@@ -291,6 +292,8 @@ 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,
|
||||
|
||||
@@ -232,7 +232,11 @@ gulp.task(
|
||||
)
|
||||
);
|
||||
|
||||
const DEMO_PAGE_ENTRIES = { "index.html": ["main"] };
|
||||
const DEMO_PAGE_ENTRIES = {
|
||||
"index.html": ["main"],
|
||||
"components.html": ["embed"],
|
||||
"embed.js": ["embed"],
|
||||
};
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-demo-dev",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* global module, require */
|
||||
// rspack/webpack loader that minifies the HTML, SVG, and CSS inside lit
|
||||
// tagged template literals using `minify-literals` (html-minifier-next +
|
||||
// lightningcss). Replaces the unmaintained babel-plugin-template-html-minifier.
|
||||
@@ -36,6 +35,14 @@ const initLoader = (browserslistEnv) => {
|
||||
return {
|
||||
minifyHTMLLiterals: minifyModule.minifyHTMLLiterals,
|
||||
lightningcssTargets,
|
||||
// With the legacy floor at Chrome 67, lightningcss rewrites rgba()
|
||||
// to 8-digit hex (#rrggbbaa). Some kiosk webviews served the legacy
|
||||
// build (e.g. Shelly Wall Display) can't parse that, so any
|
||||
// var(--divider-color) border falls back to currentColor. Always
|
||||
// compile hex alpha colors to rgba() in legacy builds.
|
||||
lightningcssInclude: browserslistEnv.startsWith("legacy")
|
||||
? lightningcssModule.Features.HexAlphaColors
|
||||
: 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -67,12 +74,13 @@ module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
|
||||
const { browserslistEnv } = this.getOptions();
|
||||
|
||||
initLoader(browserslistEnv)
|
||||
.then(({ minifyHTMLLiterals, lightningcssTargets }) =>
|
||||
.then(({ minifyHTMLLiterals, lightningcssTargets, lightningcssInclude }) =>
|
||||
minifyHTMLLiterals(source, {
|
||||
fileName: this.resourcePath,
|
||||
html: htmlOptions,
|
||||
css: {
|
||||
targets: lightningcssTargets,
|
||||
include: lightningcssInclude,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
@@ -68,8 +68,42 @@ const CONFIG_FILES = [
|
||||
path.join(__dirname, "lit-disable-dev-mode-loader.cjs"),
|
||||
path.join(__dirname, "babel-plugins", "custom-polyfill-plugin.js"),
|
||||
path.join(__dirname, "babel-plugins", "inline-constants-plugin.cjs"),
|
||||
// Its exports define the components source aliases below
|
||||
path.join(__dirname, "..", "packages", "components", "package.json"),
|
||||
];
|
||||
|
||||
// Resolve @home-assistant/components entry points to their sources, so the package
|
||||
// needs no build step during development. The entry points are read from the
|
||||
// "ha-source" export condition of the package; subpath patterns are expanded
|
||||
// for every element, i.e. every directory with a global.ts, like "card".
|
||||
const COMPONENTS_PACKAGE_DIR = path.join(
|
||||
paths.root_dir,
|
||||
"packages",
|
||||
"components"
|
||||
);
|
||||
const COMPONENTS_SRC_DIR = path.join(
|
||||
COMPONENTS_PACKAGE_DIR,
|
||||
"src",
|
||||
"components"
|
||||
);
|
||||
const componentElements = fs
|
||||
.readdirSync(COMPONENTS_SRC_DIR, { recursive: true })
|
||||
.filter((file) => path.basename(file) === "global.ts")
|
||||
.map((file) => path.dirname(file).split(path.sep).join("/"));
|
||||
const componentsSourceAliases = Object.fromEntries(
|
||||
Object.entries(require("../packages/components/package.json").exports)
|
||||
.filter(([, target]) => target["ha-source"])
|
||||
.flatMap(([subpath, target]) =>
|
||||
(subpath.includes("*") ? componentElements : [undefined]).map((name) => [
|
||||
`${path.posix.join("@home-assistant/components", name ? subpath.replaceAll("*", name) : subpath)}$`,
|
||||
path.join(
|
||||
COMPONENTS_PACKAGE_DIR,
|
||||
name ? target["ha-source"].replaceAll("*", name) : target["ha-source"]
|
||||
),
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
// Patches change a package's files but not its version, which is all the
|
||||
// node_modules snapshot checks, so their contents go into the version too.
|
||||
const PATCHES_DIR = path.join(paths.root_dir, "patches");
|
||||
@@ -214,16 +248,18 @@ const createRspackConfig = ({
|
||||
},
|
||||
},
|
||||
{
|
||||
// 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.
|
||||
// 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.
|
||||
test: /[\\/]maplibre-gl[\\/]dist[\\/]maplibre-gl(?:-worker)?\.mjs$/,
|
||||
parser: {
|
||||
exprContextCritical: false,
|
||||
url: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -242,11 +278,22 @@ const createRspackConfig = ({
|
||||
path: paths.root_dir,
|
||||
env: "legacy",
|
||||
}),
|
||||
// Keep rgba() instead of #rrggbbaa, see
|
||||
// minify-template-literals-loader.cjs
|
||||
include: { hexAlphaColors: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
// 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: [
|
||||
@@ -418,6 +465,7 @@ const createRspackConfig = ({
|
||||
"src/resources/polyfills/bigdecimal.ts"
|
||||
),
|
||||
}),
|
||||
...componentsSourceAliases,
|
||||
"lit/static-html$": "lit/static-html.js",
|
||||
"lit/decorators$": "lit/decorators.js",
|
||||
"lit/directive$": "lit/directive.js",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
|
||||
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
|
||||
Content-Security-Policy: form-action https:
|
||||
Feature-Policy: vibrate 'none'; geolocation 'none'; midi 'none'; microphone 'none'; camera 'none'; magnetometer 'none'; gyroscope 'none'; speaker 'none'; vibrate 'none'; payment 'none'
|
||||
Referrer-Policy: no-referrer-when-downgrade
|
||||
@@ -8,13 +8,13 @@
|
||||
X-XSS-Protection: 1; mode=block
|
||||
|
||||
/images/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/manifest.json
|
||||
Cache-Control: public, max-age: 3600, s-maxage=3600
|
||||
Cache-Control: public, max-age=3600, s-maxage=3600
|
||||
|
||||
/frontend_es5/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/frontend_latest/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"background_color": "#FFFFFF",
|
||||
"description": "Show Home Assistant on your Chromecast or Google Assistant devices with a screen.",
|
||||
"description": "Show Home Assistant on your Chromecast or Google Home devices with a screen.",
|
||||
"dir": "ltr",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta property="og:type" content="website" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Show Home Assistant on your Chromecast or Google Assistant devices with a screen."
|
||||
content="Show Home Assistant on your Chromecast or Google Home devices with a screen."
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
@@ -16,7 +16,7 @@
|
||||
<meta name="twitter:title" content="Home Assistant Cast" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Show Home Assistant on your Chromecast or Google Assistant devices with a screen."
|
||||
content="Show Home Assistant on your Chromecast or Google Home devices with a screen."
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<p>
|
||||
Home Assistant Cast allows you to show your Home Assistant data on a
|
||||
Chromecast device and allows you to interact with Home Assistant on
|
||||
Google Assistant devices with a screen.
|
||||
Google Home devices with a screen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mdiCast, mdiCastConnected, mdiViewDashboard } from "@mdi/js";
|
||||
import type { Auth, Connection } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { CastManager } from "../../../../src/cast/cast_manager";
|
||||
import {
|
||||
@@ -13,12 +13,14 @@ import {
|
||||
enableWrite,
|
||||
saveTokens,
|
||||
} from "../../../../src/common/auth/token_storage";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
|
||||
import { atLeastVersion } from "../../../../src/common/config/version";
|
||||
import "../../../../src/components/ha-button";
|
||||
import "../../../../src/components/ha-icon";
|
||||
import "../../../../src/components/ha-list";
|
||||
import "../../../../src/components/ha-list-item";
|
||||
import "../../../../src/components/ha-svg-icon";
|
||||
import "../../../../src/components/item/ha-list-item-option";
|
||||
import type { HaListItemOption } from "../../../../src/components/item/ha-list-item-option";
|
||||
import "../../../../src/components/list/ha-list-selectable";
|
||||
import {
|
||||
getLegacyLovelaceCollection,
|
||||
getLovelaceCollection,
|
||||
@@ -52,6 +54,9 @@ class HcCast extends LitElement {
|
||||
`
|
||||
: undefined;
|
||||
|
||||
const views = this.lovelaceViews ?? [{ title: "Home" }];
|
||||
const hasIcons = views.some((view) => view.icon);
|
||||
|
||||
return html`
|
||||
<hc-layout .auth=${this.auth} .connection=${this.connection}>
|
||||
${
|
||||
@@ -92,45 +97,41 @@ class HcCast extends LitElement {
|
||||
</p>
|
||||
`
|
||||
: html`
|
||||
<div class="section-header">PICK A VIEW</div>
|
||||
<ha-list @action=${this._handlePickView} activatable>
|
||||
${(
|
||||
this.lovelaceViews ?? [
|
||||
{
|
||||
title: "Home",
|
||||
},
|
||||
]
|
||||
).map(
|
||||
<div class="section-header" id="pick-view-header">
|
||||
PICK A VIEW
|
||||
</div>
|
||||
<ha-list-selectable
|
||||
controlled
|
||||
aria-labelledby="pick-view-header"
|
||||
>
|
||||
${views.map(
|
||||
(view, idx) => html`
|
||||
<ha-list-item
|
||||
graphic="avatar"
|
||||
.activated=${
|
||||
this.castManager.status?.lovelacePath ===
|
||||
(view.path ?? idx)
|
||||
}
|
||||
<ha-list-item-option
|
||||
data-index=${idx}
|
||||
@click=${this._handlePickView}
|
||||
.headline=${view.title || view.path || "Unnamed view"}
|
||||
.selected=${
|
||||
this.castManager.status?.lovelacePath ===
|
||||
(view.path ?? idx)
|
||||
}
|
||||
>
|
||||
${view.title || view.path || "Unnamed view"}
|
||||
${
|
||||
view.icon
|
||||
? html`
|
||||
<ha-icon
|
||||
!hasIcons
|
||||
? nothing
|
||||
: view.icon
|
||||
? html`<ha-icon
|
||||
slot="start"
|
||||
.icon=${view.icon}
|
||||
slot="graphic"
|
||||
></ha-icon>
|
||||
`
|
||||
: html`<ha-svg-icon
|
||||
slot="item-icon"
|
||||
.path=${mdiViewDashboard}
|
||||
></ha-svg-icon>`
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiViewDashboard}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
</ha-list-item>
|
||||
</ha-list-item-option>
|
||||
`
|
||||
)}</ha-list
|
||||
>
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -193,14 +194,6 @@ class HcCast extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
this.toggleAttribute(
|
||||
"hide-icons",
|
||||
this.lovelaceViews ? !this.lovelaceViews.some((view) => view.icon) : true
|
||||
);
|
||||
}
|
||||
|
||||
private async _handleSkipSaveTokens() {
|
||||
this.askWrite = false;
|
||||
}
|
||||
@@ -214,8 +207,13 @@ class HcCast extends LitElement {
|
||||
this.castManager.requestSession();
|
||||
}
|
||||
|
||||
private async _handlePickView(ev: CustomEvent<{ index: number }>) {
|
||||
const path = this.lovelaceViews?.[ev.detail.index]?.path ?? ev.detail.index;
|
||||
// Listens to clicks instead of `ha-list-item-selected`, which does not fire
|
||||
// for the selected view, so the current view can still be cast again.
|
||||
private async _handlePickView(
|
||||
ev: HASSDomCurrentTargetEvent<HaListItemOption>
|
||||
) {
|
||||
const index = Number(ev.currentTarget.dataset.index);
|
||||
const path = this.lovelaceViews?.[index]?.path ?? index;
|
||||
await ensureConnectedCastSession(this.castManager!, this.auth!);
|
||||
castSendShowLovelaceView(this.castManager, this.auth.data.hassUrl, path);
|
||||
}
|
||||
@@ -270,16 +268,11 @@ class HcCast extends LitElement {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
ha-list-item ha-icon,
|
||||
ha-list-item ha-svg-icon {
|
||||
padding: 12px;
|
||||
ha-list-item-option ha-icon,
|
||||
ha-list-item-option ha-svg-icon {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
:host([hide-icons]) ha-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ const translateErr = (err) =>
|
||||
const INTRO = html`
|
||||
<p>
|
||||
Home Assistant Cast allows you to cast your Home Assistant installation to
|
||||
Chromecast video devices and to Google Assistant devices with a screen.
|
||||
Chromecast video devices and to Google Home devices with a screen.
|
||||
</p>
|
||||
<p>
|
||||
For more information, see the
|
||||
|
||||
@@ -6,7 +6,7 @@ const playerManager = castContext.getPlayerManager();
|
||||
|
||||
playerManager.setMessageInterceptor("LOAD", (loadRequestData) => {
|
||||
const media = loadRequestData.media;
|
||||
// Special handling if it came from Google Assistant
|
||||
// Special handling if it came from Google Home
|
||||
if (media.entity) {
|
||||
media.contentId = media.entity;
|
||||
media.streamType = "LIVE";
|
||||
|
||||
@@ -13,7 +13,7 @@ export const castDemoLovelace: () => LovelaceConfig = () => {
|
||||
{
|
||||
type: "markdown",
|
||||
title: "Home Assistant Cast",
|
||||
content: `With Home Assistant you can easily create interfaces (just like this one) which can be shown on Chromecast devices connected to TVs or Google Assistant devices with a screen.${
|
||||
content: `With Home Assistant you can easily create interfaces (just like this one) which can be shown on Chromecast devices connected to TVs or Google Home devices with a screen.${
|
||||
touchSupported
|
||||
? "\n\nYou are able to interact with this demo using the touch screen."
|
||||
: "\n\nOn a Google Nest Hub you are able to interact with Home Assistant Cast via the touch screen."
|
||||
|
||||
@@ -117,7 +117,7 @@ playerManager.setMessageInterceptor("LOAD", (loadRequestData) => {
|
||||
// We received a play media command, hide Lovelace and show media player
|
||||
showMediaPlayer();
|
||||
const media = loadRequestData.media;
|
||||
// Special handling if it came from Google Assistant
|
||||
// Special handling if it came from Google Home
|
||||
if (media.entity) {
|
||||
media.contentId = media.entity;
|
||||
media.streamType = "LIVE";
|
||||
|
||||
+12
-5
@@ -1,18 +1,25 @@
|
||||
/*
|
||||
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
|
||||
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
|
||||
Content-Security-Policy: form-action https:
|
||||
Referrer-Policy: no-referrer-when-downgrade
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 1; mode=block
|
||||
|
||||
/api/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/assets/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/frontend_es5/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/frontend_latest/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
Access-Control-Allow-Origin: *
|
||||
|
||||
/embed.js
|
||||
Access-Control-Allow-Origin: *
|
||||
|
||||
/static/*
|
||||
Access-Control-Allow-Origin: *
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createContext } from "@lit/context";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
|
||||
export const embedHassContext = createContext<HomeAssistant>("embedHass");
|
||||
@@ -0,0 +1,173 @@
|
||||
import { ContextEvent } from "@lit/context";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { consume } from "../../../src/common/decorators/consume";
|
||||
import { computeRTLDirection } from "../../../src/common/util/compute_rtl";
|
||||
import { handleAction } from "../../../src/panels/lovelace/common/handle-action";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { embedHassContext } from "./context";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
import { readScriptObject } from "./script-data";
|
||||
|
||||
const pageProvider = (): HTMLElement =>
|
||||
document.querySelector("ha-embed-provider") ??
|
||||
document.body.appendChild(document.createElement("ha-embed-provider"));
|
||||
|
||||
// Looks past shadow roots, so an element 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* The base of the embed elements that render dashboard content. The element
|
||||
* 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 element adds an empty one.
|
||||
*/
|
||||
export abstract class EmbedElement<
|
||||
Config extends ScriptObject,
|
||||
> extends LitElement {
|
||||
/**
|
||||
* The configuration of the content, the same as in a dashboard. Also read
|
||||
* from a JSON or YAML script child, see `readScriptObject`.
|
||||
*/
|
||||
@property({ type: Object }) public config?: Config;
|
||||
|
||||
/** The ID of the `ha-embed-provider` to use. */
|
||||
@property() public provider?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: embedHassContext, subscribe: true })
|
||||
protected _hass?: HomeAssistant;
|
||||
|
||||
// A provider that is not an ancestor, which gets the context requests of the
|
||||
// element and its content. Context requests only reach ancestors by
|
||||
// themselves.
|
||||
private _linkedProvider?: HTMLElement;
|
||||
|
||||
private _scriptRead = false;
|
||||
|
||||
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 [origin] = ev.composedPath();
|
||||
const node = origin instanceof HTMLElement ? origin : this;
|
||||
|
||||
handleAction(node, this._hass, ev.detail.config, ev.detail.action);
|
||||
}
|
||||
});
|
||||
// The page's address belongs to the page, so only a hash on it, such as
|
||||
// an anchor, navigates there. The browser does that itself, as the app's
|
||||
// navigate() doesn't scroll to the anchor.
|
||||
this.addEventListener("hass-navigate", (ev) => {
|
||||
const { path, replace } = ev.detail;
|
||||
|
||||
ev.preventDefault();
|
||||
|
||||
if (!path.startsWith("#")) {
|
||||
this.navigateTo(path, replace);
|
||||
} else if (replace) {
|
||||
window.location.replace(path);
|
||||
} else {
|
||||
window.location.hash = path;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 element keeps a config set since
|
||||
if (this._scriptRead) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._scriptRead = true;
|
||||
|
||||
const data = readScriptObject(this);
|
||||
|
||||
if (data !== undefined) {
|
||||
this.setScriptData(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A navigate action of the content, other than to a hash on the page. It
|
||||
* leaves the page where it is, like actions that open a dialog. `replace`
|
||||
* is the action's `navigation_replace`.
|
||||
*/
|
||||
protected navigateTo(_path: string, _replace?: boolean) {
|
||||
// Nothing to navigate to, outside a dashboard
|
||||
}
|
||||
|
||||
/** Whether the data of a script child is a configuration of the content. */
|
||||
protected abstract isConfig(data: ScriptObject): data is Config;
|
||||
|
||||
/** Sets the data of a JSON or YAML script child, see `readScriptObject`. */
|
||||
protected setScriptData(data: ScriptObject) {
|
||||
if (this.isConfig(data)) {
|
||||
this.config = data;
|
||||
}
|
||||
}
|
||||
|
||||
/** The content, or `undefined` while there is nothing to render. */
|
||||
protected abstract renderContent(
|
||||
hass: HomeAssistant
|
||||
): TemplateResult | undefined;
|
||||
|
||||
// The content in the theme of the provider
|
||||
protected render() {
|
||||
const hass = this._hass;
|
||||
const content = hass && this.renderContent(hass);
|
||||
|
||||
if (!hass || !content) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<div
|
||||
class=${classMap({ theme: true, dark: hass.themes.darkMode })}
|
||||
dir=${computeRTLDirection(hass)}
|
||||
>
|
||||
${content}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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";
|
||||
import "./ha-embed-section";
|
||||
import "./ha-embed-view";
|
||||
import "./ha-embed-dashboard";
|
||||
// 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");
|
||||
@@ -0,0 +1,99 @@
|
||||
import { css, html } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
|
||||
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 { EmbedElement } from "./embed-element";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
import { embedElementStyles } from "./theme-styles";
|
||||
|
||||
/**
|
||||
* Renders a Lovelace card with the backend of a provider, see `EmbedElement`.
|
||||
*
|
||||
* 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 EmbedElement<LovelaceCardConfig> {
|
||||
private _card = createRef<HuiCard>();
|
||||
|
||||
// A card names its type
|
||||
protected isConfig(data: ScriptObject): data is LovelaceCardConfig {
|
||||
return "type" in data && typeof data.type === "string";
|
||||
}
|
||||
|
||||
protected renderContent(hass: HomeAssistant) {
|
||||
if (!this.config) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return html`<hui-card
|
||||
${ref(this._card)}
|
||||
.hass=${hass}
|
||||
.config=${this.config}
|
||||
.layout=${"grid"}
|
||||
@card-updated=${this._updateSize}
|
||||
@card-visibility-changed=${this._updateSize}
|
||||
></hui-card>`;
|
||||
}
|
||||
|
||||
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 = [
|
||||
embedElementStyles,
|
||||
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([fit-rows]) .theme {
|
||||
height: calc(
|
||||
(var(--row-size, 1) * (var(--row-height) + var(--row-gap))) - var(
|
||||
--row-gap
|
||||
)
|
||||
);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-embed-card": HaEmbedCard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
|
||||
import type {
|
||||
LovelaceConfig,
|
||||
LovelaceRawConfig,
|
||||
} from "../../../src/data/lovelace/config/types";
|
||||
import "../../../src/panels/lovelace/hui-root";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { EmbedLovelaceElement, isDashboardConfig } from "./lovelace";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
|
||||
/**
|
||||
* Renders a dashboard, or a dashboard strategy, with its tabs, with the
|
||||
* backend of a provider, see `EmbedElement`. Each dashboard keeps its own
|
||||
* view in `view`, so switching tabs and following links to its views don't
|
||||
* change the page's URL. The dashboard scrolls inside the element, under its
|
||||
* toolbar.
|
||||
*/
|
||||
@customElement("ha-embed-dashboard")
|
||||
export class HaEmbedDashboard extends EmbedLovelaceElement<LovelaceRawConfig> {
|
||||
private _scroller = createRef<HTMLDivElement>();
|
||||
|
||||
// The route of hui-root, here without a page URL around it. A new
|
||||
// configuration gives a new route, as hui-root only looks up the view of
|
||||
// the path again when its route changes, and the views may have moved.
|
||||
private _route = memoizeOne(
|
||||
(view: string | undefined, _config: LovelaceConfig) => ({
|
||||
prefix: "",
|
||||
path: `/${view ?? ""}`,
|
||||
})
|
||||
);
|
||||
|
||||
protected get rawConfig() {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
protected isConfig(data: ScriptObject): data is LovelaceRawConfig {
|
||||
return isDashboardConfig(data);
|
||||
}
|
||||
|
||||
// The view shown, and the views before it, which the back button of a
|
||||
// subview without a `back_path` goes back through, as the browser's back
|
||||
// would in the app
|
||||
private _shownView?: string;
|
||||
|
||||
private _history: (string | undefined)[] = [];
|
||||
|
||||
// Going back, or a navigate action with `navigation_replace`, which
|
||||
// replaces the view shown rather than adding to the views before it
|
||||
private _skipHistory = false;
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
|
||||
if (this.view === this._shownView) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._shownView !== undefined) {
|
||||
if (!this._skipHistory) {
|
||||
this._history.push(this._shownView);
|
||||
}
|
||||
|
||||
this._scroller.value?.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
this._skipHistory = false;
|
||||
this._shownView = this.view;
|
||||
}
|
||||
|
||||
protected navigateTo(path: string, replace?: boolean) {
|
||||
const view = this.viewOfPath(path);
|
||||
|
||||
if (view === undefined || this.isSameView(view, this.view)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._skipHistory = Boolean(replace);
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
// The embedded attribute is in the template, so that hui-root has it when
|
||||
// it connects and skips the listeners on the page
|
||||
protected renderContent(hass: HomeAssistant) {
|
||||
if (!this._lovelace?.config.views.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return html`<div class="scroller" ${ref(this._scroller)}>
|
||||
<hui-root
|
||||
embedded
|
||||
no-edit
|
||||
.hass=${hass}
|
||||
.lovelace=${this._lovelace}
|
||||
.route=${this._route(this.view, this._lovelace.config)}
|
||||
.narrow=${this._narrow.value ?? false}
|
||||
@view-navigate=${this._viewNavigate}
|
||||
></hui-root>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// A tab, or the back button of a subview, which goes to its `back_path`
|
||||
// when that is one of the dashboard's views, else to the view before it
|
||||
private _viewNavigate(ev: HASSDomEvent<HASSDomEvents["view-navigate"]>) {
|
||||
const view = this.viewOfPath(ev.detail.path);
|
||||
|
||||
if (!ev.detail.back) {
|
||||
if (view !== undefined) {
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const target = view === undefined ? this._previousView() : view;
|
||||
|
||||
// Following a `back_path` goes back too, so the view it goes to leaves
|
||||
// the views before
|
||||
if (
|
||||
view !== undefined &&
|
||||
this.isSameView(this._history[this._history.length - 1], view)
|
||||
) {
|
||||
this._history.pop();
|
||||
}
|
||||
|
||||
if (!this.isSameView(target, this.view)) {
|
||||
this._skipHistory = true;
|
||||
this.view = target;
|
||||
}
|
||||
}
|
||||
|
||||
// The last view before that isn't the one shown, else the first view
|
||||
private _previousView(): string | undefined {
|
||||
while (this._history.length) {
|
||||
const previous = this._history.pop();
|
||||
|
||||
if (!this.isSameView(previous, this.view)) {
|
||||
return previous;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
EmbedLovelaceElement.styles,
|
||||
css`
|
||||
/* Contained, so that the toolbar and background of hui-root, which are
|
||||
fixed to the window in the app, are fixed to the element */
|
||||
:host {
|
||||
height: 600px;
|
||||
contain: layout paint;
|
||||
}
|
||||
.theme,
|
||||
.scroller {
|
||||
height: 100%;
|
||||
}
|
||||
.scroller {
|
||||
overflow: auto;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-embed-dashboard": HaEmbedDashboard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
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 { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
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 type { DemoDevice } from "../stubs/device_registry";
|
||||
import { toDeviceRegistryEntry } from "../stubs/device_registry";
|
||||
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[];
|
||||
devices?: DemoDevice[];
|
||||
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 embed elements inside it.
|
||||
* Elements 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`, `devices`, `calendars` and `energy`.
|
||||
*/
|
||||
@property({ type: Array }) public entities: EntityInput[] = [];
|
||||
|
||||
/**
|
||||
* All areas. Without them, an area for each `area_id` of the entities and
|
||||
* devices.
|
||||
*/
|
||||
@property({ attribute: false }) public areas?: DemoArea[];
|
||||
|
||||
/** All devices, which entities reference with their `device_id`. */
|
||||
@property({ attribute: false }) public devices?: DemoDevice[];
|
||||
|
||||
/**
|
||||
* 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)
|
||||
);
|
||||
hass.mockWS("config/device_registry/list", (_msg, currentHass) =>
|
||||
Object.values(currentHass.devices)
|
||||
);
|
||||
// The registry of the entities, with the options each entity sets, such
|
||||
// as timer presets. Other settings, such as calendar colors, are not set.
|
||||
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,
|
||||
options:
|
||||
this.entities.find((item) => item.entity_id === entity.entity_id)
|
||||
?.options ?? 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.devices = data.devices;
|
||||
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") ||
|
||||
changedProps.has("devices")
|
||||
) {
|
||||
const devices = this.devices ?? [];
|
||||
const areas: DemoArea[] =
|
||||
this.areas ??
|
||||
[
|
||||
...new Set(
|
||||
[...this.entities, ...devices].flatMap((item) =>
|
||||
item.area_id === undefined ? [] : [item.area_id]
|
||||
)
|
||||
),
|
||||
].map((areaId) => ({ area_id: areaId, name: areaName(areaId) }));
|
||||
|
||||
const areasById: Record<string, AreaRegistryEntry> = {};
|
||||
areas.forEach((area) => {
|
||||
areasById[area.area_id] = toAreaRegistryEntry(area);
|
||||
});
|
||||
const devicesById: Record<string, DeviceRegistryEntry> = {};
|
||||
devices.forEach((device) => {
|
||||
devicesById[device.id] = toDeviceRegistryEntry(device);
|
||||
});
|
||||
// Before the entities, so that their names can include the area and
|
||||
// device
|
||||
this.hass.updateHass({ areas: areasById, devices: devicesById });
|
||||
|
||||
// Entities that services changed keep their state when only the areas
|
||||
// or devices 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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
|
||||
import { normalizeSectionConfig } from "../../../src/data/lovelace/config/normalize";
|
||||
import type { LovelaceSectionRawConfig } from "../../../src/data/lovelace/config/section";
|
||||
import "../../../src/panels/lovelace/sections/hui-section";
|
||||
import {
|
||||
renderSectionContainer,
|
||||
sectionContainerStyles,
|
||||
} from "../../../src/panels/lovelace/sections/section-container";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { EmbedElement } from "./embed-element";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
import { embedElementStyles } from "./theme-styles";
|
||||
|
||||
/**
|
||||
* Renders a dashboard section, or a section strategy, with the backend of a
|
||||
* provider, see `EmbedElement`. The section takes the width of the element.
|
||||
*/
|
||||
@customElement("ha-embed-section")
|
||||
export class HaEmbedSection extends EmbedElement<LovelaceSectionRawConfig> {
|
||||
// A title becomes a heading card, as on a dashboard
|
||||
private _normalizedConfig = memoizeOne(normalizeSectionConfig);
|
||||
|
||||
// A section has cards, sections inside it, a strategy that makes them, a
|
||||
// title, or the type of a custom section
|
||||
protected isConfig(data: ScriptObject): data is LovelaceSectionRawConfig {
|
||||
return (
|
||||
"cards" in data ||
|
||||
"sections" in data ||
|
||||
"strategy" in data ||
|
||||
"title" in data ||
|
||||
("type" in data && typeof data.type === "string")
|
||||
);
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
// The section that hid the element is gone, and the next one starts
|
||||
// visible without telling it
|
||||
if (changedProps.has("config") && !this.config) {
|
||||
this.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
// In the container that draws the background of the section, as in a
|
||||
// sections view
|
||||
protected renderContent(hass: HomeAssistant) {
|
||||
if (!this.config) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const config = this._normalizedConfig(this.config);
|
||||
|
||||
return renderSectionContainer(
|
||||
hass,
|
||||
config,
|
||||
html`<hui-section
|
||||
.hass=${hass}
|
||||
.config=${config}
|
||||
@section-visibility-changed=${this._visibilityChanged}
|
||||
></hui-section>`
|
||||
);
|
||||
}
|
||||
|
||||
private _visibilityChanged(
|
||||
ev: HASSDomEvent<HASSDomEvents["section-visibility-changed"]>
|
||||
) {
|
||||
// Sections inside the section fire the same event
|
||||
if (ev.target !== ev.currentTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hidden = !ev.detail.value;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
embedElementStyles,
|
||||
sectionContainerStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-embed-section": HaEmbedSection;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { keyed } from "lit/directives/keyed";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type {
|
||||
LovelaceConfig,
|
||||
LovelaceRawConfig,
|
||||
} from "../../../src/data/lovelace/config/types";
|
||||
import type { LovelaceViewRawConfig } from "../../../src/data/lovelace/config/view";
|
||||
import "../../../src/panels/lovelace/views/hui-view";
|
||||
import "../../../src/panels/lovelace/views/hui-view-background";
|
||||
import "../../../src/panels/lovelace/views/hui-view-container";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import {
|
||||
EmbedLovelaceElement,
|
||||
findViewIndex,
|
||||
isDashboardConfig,
|
||||
} from "./lovelace";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
|
||||
/**
|
||||
* Renders a dashboard view, a view strategy, or one view of a dashboard or
|
||||
* dashboard strategy, with the backend of a provider, see `EmbedElement`.
|
||||
* `config` holds the view.
|
||||
*/
|
||||
@customElement("ha-embed-view")
|
||||
export class HaEmbedView extends EmbedLovelaceElement<LovelaceViewRawConfig> {
|
||||
/**
|
||||
* A dashboard configuration or dashboard strategy, to show its `view`
|
||||
* instead of `config`. Also read from the `dashboard` key of a script child,
|
||||
* next to `view`.
|
||||
*/
|
||||
@property({ type: Object }) public dashboard?: LovelaceRawConfig;
|
||||
|
||||
private _viewDashboard = memoizeOne(
|
||||
(view: LovelaceViewRawConfig): LovelaceConfig => ({ views: [view] })
|
||||
);
|
||||
|
||||
protected get rawConfig() {
|
||||
return this.dashboard ?? (this.config && this._viewDashboard(this.config));
|
||||
}
|
||||
|
||||
// A view strategy and a dashboard strategy both have a `strategy` key, so
|
||||
// a dashboard comes in its own key, which no view has
|
||||
protected isConfig(data: ScriptObject): data is LovelaceViewRawConfig {
|
||||
return !("dashboard" in data);
|
||||
}
|
||||
|
||||
protected setScriptData(data: ScriptObject) {
|
||||
if (!("dashboard" in data)) {
|
||||
super.setScriptData(data);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { dashboard } = data;
|
||||
|
||||
if (
|
||||
typeof dashboard === "object" &&
|
||||
dashboard !== null &&
|
||||
isDashboardConfig(dashboard)
|
||||
) {
|
||||
this.dashboard = dashboard;
|
||||
}
|
||||
|
||||
if (
|
||||
"view" in data &&
|
||||
(typeof data.view === "string" || typeof data.view === "number")
|
||||
) {
|
||||
this.view ??= String(data.view);
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing for a dashboard without views, such as an empty strategy result.
|
||||
// The view's theme and background, or the dashboard's background, as in
|
||||
// hui-root. Each view gets its own hui-view, as there, so that a view
|
||||
// strategy still generating for the view before can't replace the cards of
|
||||
// this one.
|
||||
protected renderContent(hass: HomeAssistant) {
|
||||
const views = this._lovelace?.config.views;
|
||||
|
||||
if (!this._lovelace || !views?.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const index = findViewIndex(views, this.view);
|
||||
const viewConfig = views[index];
|
||||
|
||||
const background =
|
||||
viewConfig.background || this._lovelace.config.background;
|
||||
|
||||
return html`<hui-view-container .hass=${hass} .theme=${viewConfig.theme}>
|
||||
<hui-view-background .hass=${hass} .background=${background}>
|
||||
</hui-view-background>
|
||||
${keyed(
|
||||
viewConfig,
|
||||
html`<hui-view
|
||||
.hass=${hass}
|
||||
.lovelace=${this._lovelace}
|
||||
.index=${index}
|
||||
.narrow=${this._narrow.value ?? false}
|
||||
></hui-view>`
|
||||
)}
|
||||
</hui-view-container>`;
|
||||
}
|
||||
|
||||
// A fixed background stays inside the element. With a height set on the
|
||||
// element, a panel view fills it, and other views keep their own height.
|
||||
static styles = [
|
||||
EmbedLovelaceElement.styles,
|
||||
css`
|
||||
:host {
|
||||
contain: layout paint;
|
||||
}
|
||||
.theme,
|
||||
hui-view-container,
|
||||
hui-view {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
/* The background sits below the view, above the element's own */
|
||||
hui-view-container {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-embed-view": HaEmbedView;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css } from "lit";
|
||||
import { property, state } from "lit/decorators";
|
||||
import { normalizeLovelaceConfig } from "../../../src/data/lovelace/config/normalize";
|
||||
import type { LovelaceRawConfig } from "../../../src/data/lovelace/config/types";
|
||||
import { isStrategyDashboard } from "../../../src/data/lovelace/config/types";
|
||||
import type { LovelaceViewRawConfig } from "../../../src/data/lovelace/config/view";
|
||||
import {
|
||||
checkStrategyShouldRegenerate,
|
||||
generateLovelaceDashboardStrategy,
|
||||
} from "../../../src/panels/lovelace/strategies/get-strategy";
|
||||
import type { Lovelace } from "../../../src/panels/lovelace/types";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { EmbedElement } from "./embed-element";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
import { embedElementStyles } from "./theme-styles";
|
||||
|
||||
// The width below which the app is narrow, see home-assistant-main
|
||||
const NARROW_WIDTH = 870;
|
||||
|
||||
/** The index of the view with this path or index, if there is one. */
|
||||
const viewIndex = (
|
||||
views: LovelaceViewRawConfig[],
|
||||
view: string
|
||||
): number | undefined => {
|
||||
const byPath = views.findIndex((item) => item.path === view);
|
||||
|
||||
if (byPath !== -1) {
|
||||
return byPath;
|
||||
}
|
||||
|
||||
const index = Number(view);
|
||||
|
||||
return Number.isInteger(index) && index >= 0 && index < views.length
|
||||
? index
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Whether script data is a dashboard: its views, or a strategy for them. */
|
||||
export const isDashboardConfig = (
|
||||
data: ScriptObject
|
||||
): data is LovelaceRawConfig => "views" in data || "strategy" in data;
|
||||
|
||||
/** The index of the view with this path or index, else the first view. */
|
||||
export const findViewIndex = (
|
||||
views: LovelaceViewRawConfig[],
|
||||
view?: string
|
||||
): number => (view === undefined ? 0 : (viewIndex(views, view) ?? 0));
|
||||
|
||||
/**
|
||||
* The base of the embed elements that render a dashboard, or one of its
|
||||
* views. It generates dashboard strategies, again when the registries they
|
||||
* depend on change, and holds a dashboard that can't be edited or saved.
|
||||
*/
|
||||
export abstract class EmbedLovelaceElement<
|
||||
Config extends ScriptObject,
|
||||
> extends EmbedElement<Config> {
|
||||
/** The path or index of the view to show. Without it, the first view. */
|
||||
@property() public view?: string;
|
||||
|
||||
@state() protected _lovelace?: Lovelace;
|
||||
|
||||
// Narrow like the app at the same width, measured on the element because
|
||||
// it rarely takes the width of the page
|
||||
protected _narrow = new ResizeController(this, {
|
||||
callback: (entries) =>
|
||||
(entries[0]?.contentRect.width ?? NARROW_WIDTH) < NARROW_WIDTH,
|
||||
});
|
||||
|
||||
// The configuration of the dashboard in `_lovelace`, or being generated
|
||||
private _rawConfig?: LovelaceRawConfig;
|
||||
|
||||
// Counts the generations started, so that only the latest one is shown.
|
||||
// Generating again for changed registries keeps the same configuration.
|
||||
private _generation = 0;
|
||||
|
||||
// The backend at the last update, to tell what a strategy depends on changed
|
||||
private _previousHass?: HomeAssistant;
|
||||
|
||||
/** The dashboard to render. The same object until it changes. */
|
||||
protected abstract get rawConfig(): LovelaceRawConfig | undefined;
|
||||
|
||||
/**
|
||||
* The view of the dashboard a path goes to, relative like `batteries` or
|
||||
* with the dashboard like `/maintenance/batteries`, if it has that view.
|
||||
*/
|
||||
protected viewOfPath(path: string | undefined): string | undefined {
|
||||
const views = this._lovelace?.config.views;
|
||||
|
||||
const view = path
|
||||
? decodeURI(new URL(path, "http://embed/").pathname)
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.pop()
|
||||
: undefined;
|
||||
|
||||
return views && view !== undefined && viewIndex(views, view) !== undefined
|
||||
? view
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Whether two paths or indexes go to the same view of the dashboard. */
|
||||
protected isSameView(a?: string, b?: string): boolean {
|
||||
const views = this._lovelace?.config.views;
|
||||
|
||||
return views
|
||||
? findViewIndex(views, a) === findViewIndex(views, b)
|
||||
: a === b;
|
||||
}
|
||||
|
||||
// A path to one of the dashboard's views shows that view. Other paths leave
|
||||
// the dashboard where it is.
|
||||
protected navigateTo(path: string) {
|
||||
const view = this.viewOfPath(path);
|
||||
|
||||
if (view !== undefined) {
|
||||
this.view = view;
|
||||
}
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
const raw = this.rawConfig;
|
||||
const oldHass = this._previousHass;
|
||||
|
||||
this._previousHass = this._hass;
|
||||
|
||||
// Without a configuration, nothing is shown, and a generation still
|
||||
// running is dropped
|
||||
if (!raw) {
|
||||
if (this._rawConfig) {
|
||||
this._rawConfig = undefined;
|
||||
this._lovelace = undefined;
|
||||
this._generation++;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A strategy is only asked whether to generate again when the backend
|
||||
// changed, as in ha-panel-lovelace, not when the generated dashboard
|
||||
// arrives
|
||||
if (
|
||||
raw !== this._rawConfig ||
|
||||
(oldHass &&
|
||||
oldHass !== this._hass &&
|
||||
isStrategyDashboard(raw) &&
|
||||
this._hass.config.state === "RUNNING" &&
|
||||
checkStrategyShouldRegenerate(
|
||||
"dashboard",
|
||||
raw.strategy,
|
||||
oldHass,
|
||||
this._hass
|
||||
))
|
||||
) {
|
||||
this._rawConfig = raw;
|
||||
this._generate(raw, this._hass, ++this._generation);
|
||||
}
|
||||
}
|
||||
|
||||
private async _generate(
|
||||
raw: LovelaceRawConfig,
|
||||
hass: HomeAssistant,
|
||||
generation: number
|
||||
) {
|
||||
const generated = isStrategyDashboard(raw)
|
||||
? await generateLovelaceDashboardStrategy(raw, hass)
|
||||
: raw;
|
||||
|
||||
const config = normalizeLovelaceConfig(generated);
|
||||
|
||||
// A newer generation has started. A generated dashboard has its views,
|
||||
// rather than a strategy.
|
||||
if (generation !== this._generation || isStrategyDashboard(config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._lovelace = {
|
||||
config,
|
||||
rawConfig: raw,
|
||||
editMode: false,
|
||||
urlPath: null,
|
||||
mode: "generated",
|
||||
locale: hass.locale,
|
||||
enableFullEditMode: () => undefined,
|
||||
saveConfig: async () => undefined,
|
||||
deleteConfig: async () => undefined,
|
||||
setEditMode: () => undefined,
|
||||
showToast: () => undefined,
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
// The background of a dashboard, and no insets for a notch or home bar
|
||||
static styles: CSSResultGroup = [
|
||||
embedElementStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
--app-safe-area-inset-top: 0px;
|
||||
--app-safe-area-inset-bottom: 0px;
|
||||
--app-safe-area-inset-left: 0px;
|
||||
--app-safe-area-inset-right: 0px;
|
||||
}
|
||||
.theme {
|
||||
position: relative;
|
||||
background: var(--primary-background-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* 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 {};
|
||||
@@ -0,0 +1,31 @@
|
||||
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!);
|
||||
};
|
||||
|
||||
/** The data of a script child that holds an object, such as a config. */
|
||||
export type ScriptObject = object;
|
||||
|
||||
/** The object in a script child, see `readScriptData`, if it holds one. */
|
||||
export const readScriptObject = (
|
||||
element: HTMLElement
|
||||
): ScriptObject | undefined => {
|
||||
const data = readScriptData(element);
|
||||
|
||||
return typeof data === "object" && data !== null && !Array.isArray(data)
|
||||
? data
|
||||
: undefined;
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { css, 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("")}}`
|
||||
);
|
||||
|
||||
/**
|
||||
* The styles of every embed element: the theme, and the page styles of the
|
||||
* app, for the `.theme` wrapper of `EmbedElement`.
|
||||
*/
|
||||
export const embedElementStyles = [
|
||||
embedThemeStyles,
|
||||
css`
|
||||
:host([hidden]) {
|
||||
display: none;
|
||||
}
|
||||
/* The text styles the app sets on html and body, so that the content 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 content */
|
||||
.theme[dir="rtl"] {
|
||||
--direction: rtl;
|
||||
--float-start: right;
|
||||
--float-end: left;
|
||||
--scale-direction: -1;
|
||||
}
|
||||
`,
|
||||
];
|
||||
@@ -0,0 +1,32 @@
|
||||
// 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 {};
|
||||
@@ -36,6 +36,7 @@ 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";
|
||||
@@ -50,6 +51,7 @@ 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.
|
||||
@@ -75,6 +77,7 @@ const CONFIG_PANEL_COMMANDS = [
|
||||
"get_conditions_for_target",
|
||||
"get_services_for_target",
|
||||
"extract_from_target",
|
||||
"explain_target",
|
||||
"script/config",
|
||||
"config/automation/config",
|
||||
"config/script/config",
|
||||
@@ -190,7 +193,9 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
mockSystemLog(hass);
|
||||
mockTemplate(hass);
|
||||
mockEvents(hass);
|
||||
mockLogbook(hass);
|
||||
mockMediaPlayer(hass);
|
||||
mockWeather(hass);
|
||||
mockFrontend(hass);
|
||||
mockHardware(hass);
|
||||
mockHassioSupervisor(hass);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
import "<%= latestEntryJS[0] %>";
|
||||
@@ -12,6 +12,20 @@ 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[] = []
|
||||
@@ -25,19 +39,7 @@ export const setDemoAreas = (
|
||||
hass: MockHomeAssistant,
|
||||
demoAreas: DemoArea[] = []
|
||||
) => {
|
||||
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,
|
||||
}));
|
||||
areas = demoAreas.map(toAreaRegistryEntry);
|
||||
const areasById: Record<string, AreaRegistryEntry> = {};
|
||||
areas.forEach((area) => {
|
||||
areasById[area.area_id] = area;
|
||||
|
||||
@@ -2,14 +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 { explainTarget } from "./explain_target";
|
||||
import { expandTarget, mockExtractFromTarget } from "./target";
|
||||
|
||||
type Descriptions = TriggerDescriptions | ConditionDescriptions;
|
||||
|
||||
@@ -20,109 +20,6 @@ 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,
|
||||
@@ -228,27 +125,20 @@ 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",
|
||||
"explain_target",
|
||||
(
|
||||
msg: { target: HassServiceTarget; primary_entities_only?: boolean },
|
||||
currentHass: MockHomeAssistant
|
||||
): ExtractFromTargetResult => {
|
||||
const { areas, devices, entities, missing } = expandTarget(
|
||||
currentHass,
|
||||
) =>
|
||||
explainTarget(
|
||||
msg.target,
|
||||
(target, primaryEntitiesOnly) =>
|
||||
expandTarget(currentHass, target, primaryEntitiesOnly),
|
||||
Object.keys(currentHass.states),
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
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")
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -1,6 +1,60 @@
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
/** The fields of a device that the demo devices leave at their default. */
|
||||
export const baseDevice: Omit<
|
||||
DeviceRegistryEntry,
|
||||
| "id"
|
||||
| "name"
|
||||
| "manufacturer"
|
||||
| "model"
|
||||
| "config_entries"
|
||||
| "primary_config_entry"
|
||||
| "entry_type"
|
||||
> = {
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
identifiers: [],
|
||||
model_id: null,
|
||||
labels: [],
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
serial_number: null,
|
||||
via_device_id: null,
|
||||
area_id: null,
|
||||
name_by_user: null,
|
||||
disabled_by: null,
|
||||
configuration_url: null,
|
||||
parent_device_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
|
||||
export interface DemoDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
area_id?: string;
|
||||
via_device_id?: string;
|
||||
entry_type?: "service";
|
||||
}
|
||||
|
||||
export const toDeviceRegistryEntry = (
|
||||
device: DemoDevice
|
||||
): DeviceRegistryEntry => ({
|
||||
...baseDevice,
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
manufacturer: device.manufacturer ?? null,
|
||||
model: device.model ?? null,
|
||||
area_id: device.area_id ?? null,
|
||||
via_device_id: device.via_device_id ?? null,
|
||||
entry_type: device.entry_type ?? null,
|
||||
config_entries: [],
|
||||
primary_config_entry: null,
|
||||
});
|
||||
|
||||
export const mockDeviceRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
data: DeviceRegistryEntry[] = []
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
import { connectivityDevices } from "./connectivity/fixtures";
|
||||
|
||||
const baseDevice = {
|
||||
config_entries_subentries: {},
|
||||
connections: [] as [string, string][],
|
||||
identifiers: [] as [string, string][],
|
||||
model_id: null,
|
||||
labels: [] as string[],
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
serial_number: null,
|
||||
via_device_id: null,
|
||||
area_id: null,
|
||||
name_by_user: null,
|
||||
disabled_by: null,
|
||||
configuration_url: null,
|
||||
parent_device_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
import { baseDevice } from "./device_registry";
|
||||
|
||||
export const demoDevices: DeviceRegistryEntry[] = [
|
||||
{
|
||||
|
||||
@@ -97,6 +97,14 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
|
||||
stat_consumption: "sensor.water_garden",
|
||||
},
|
||||
],
|
||||
device_consumption_gas: [
|
||||
{
|
||||
stat_consumption: "sensor.gas_kitchen",
|
||||
},
|
||||
{
|
||||
stat_consumption: "sensor.gas_heat_pump",
|
||||
},
|
||||
],
|
||||
}));
|
||||
hass.mockWS("energy/info", (): EnergyInfo => ({
|
||||
cost_sensors: {},
|
||||
@@ -106,6 +114,7 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
|
||||
energy_sources: Array.from({ length: 6 }, () => []),
|
||||
device_consumption: Array.from({ length: 6 }, () => []),
|
||||
device_consumption_water: Array.from({ length: 2 }, () => []),
|
||||
device_consumption_gas: Array.from({ length: 2 }, () => []),
|
||||
}));
|
||||
hass.mockWS(
|
||||
"energy/fossil_energy_consumption",
|
||||
|
||||
+40
-20
@@ -11,7 +11,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.solar_production",
|
||||
state: "88.6",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Solar",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -20,7 +20,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.battery_input",
|
||||
state: "4",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Battery Input",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -29,7 +29,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.battery_output",
|
||||
state: "3",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
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: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
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: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
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: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
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: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
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: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
unit_of_measurement: "EUR",
|
||||
},
|
||||
},
|
||||
@@ -82,7 +82,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_consumption_tarif_2_cost",
|
||||
state: "2",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
unit_of_measurement: "EUR",
|
||||
},
|
||||
},
|
||||
@@ -90,7 +90,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_production_tarif_1_compensation",
|
||||
state: "2",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
unit_of_measurement: "EUR",
|
||||
},
|
||||
},
|
||||
@@ -98,7 +98,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_production_tarif_2_compensation",
|
||||
state: "2",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
unit_of_measurement: "EUR",
|
||||
},
|
||||
},
|
||||
@@ -138,7 +138,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_gas_cost",
|
||||
state: "2",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
unit_of_measurement: "EUR",
|
||||
},
|
||||
},
|
||||
@@ -146,7 +146,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_gas",
|
||||
state: "4",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Gas",
|
||||
unit_of_measurement: "m³",
|
||||
},
|
||||
@@ -155,16 +155,36 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_water",
|
||||
state: "4000",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Water",
|
||||
unit_of_measurement: "L",
|
||||
},
|
||||
},
|
||||
"sensor.gas_kitchen": {
|
||||
entity_id: "sensor.gas_kitchen",
|
||||
state: "100",
|
||||
attributes: {
|
||||
friendly_name: "Gas kitchen",
|
||||
unit_of_measurement: "m³",
|
||||
device_class: "gas",
|
||||
state_class: "total_increasing",
|
||||
},
|
||||
},
|
||||
"sensor.gas_heat_pump": {
|
||||
entity_id: "sensor.gas_heat_pump",
|
||||
state: "500",
|
||||
attributes: {
|
||||
friendly_name: "Gas heat pump",
|
||||
unit_of_measurement: "m³",
|
||||
device_class: "gas",
|
||||
state_class: "total_increasing",
|
||||
},
|
||||
},
|
||||
"sensor.energy_car": {
|
||||
entity_id: "sensor.energy_car",
|
||||
state: "4",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Electric car",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -173,7 +193,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_ac",
|
||||
state: "3",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Air conditioning",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -182,7 +202,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_washing_machine",
|
||||
state: "6",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Washing machine",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -191,7 +211,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_dryer",
|
||||
state: "5.5",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Dryer",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -200,7 +220,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_heat_pump",
|
||||
state: "6",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Heat pump",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -209,7 +229,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_boiler",
|
||||
state: "7",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Boiler",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
let entries: EntityRegistryEntry[] = [];
|
||||
|
||||
const newEntry = (
|
||||
export const newEntry = (
|
||||
entry: Pick<EntityRegistryEntry, "entity_id" | "platform" | "unique_id">
|
||||
): EntityRegistryEntry => {
|
||||
const now = Date.now() / 1000;
|
||||
@@ -43,7 +43,9 @@ const displayFields = (entry: EntityRegistryEntry) => ({
|
||||
hidden: entry.hidden_by !== null,
|
||||
});
|
||||
|
||||
const extEntry = (entry: EntityRegistryEntry): ExtEntityRegistryEntry => ({
|
||||
export const extEntry = (
|
||||
entry: EntityRegistryEntry
|
||||
): ExtEntityRegistryEntry => ({
|
||||
...entry,
|
||||
capabilities: {},
|
||||
aliases: [],
|
||||
@@ -56,6 +58,16 @@ 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[] }) => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { ensureArray } from "../../../src/common/array/ensure-array";
|
||||
import type {
|
||||
TargetExplanation,
|
||||
HassServiceTargetWithExclude,
|
||||
TargetItem,
|
||||
TargetType,
|
||||
} from "../../../src/data/target";
|
||||
|
||||
const TYPES: TargetType[] = ["entity", "device", "area", "floor", "label"];
|
||||
|
||||
const items = (target?: HassServiceTarget): TargetItem[] =>
|
||||
TYPES.flatMap((type) => {
|
||||
const ids = target?.[`${type}_id`];
|
||||
return ids ? ensureArray(ids).map((id) => ({ type, id: String(id) })) : [];
|
||||
});
|
||||
|
||||
/** What one target item resolves to, and the areas and devices it reaches. */
|
||||
export interface ResolvedTargetItem {
|
||||
areas: string[];
|
||||
devices: string[];
|
||||
entities: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Like core's explain_target: resolves each item of the target and of its
|
||||
* `exclude` on its own with `resolve`, and tells what each exclude takes out
|
||||
* of each included item. Like core, `entity_id: all` is every entity in
|
||||
* `entityIds`, and excludes resolve every entity, not only the primary ones.
|
||||
*/
|
||||
export const explainTarget = (
|
||||
target: HassServiceTargetWithExclude,
|
||||
resolve: (
|
||||
target: HassServiceTarget,
|
||||
primaryEntitiesOnly: boolean
|
||||
) => ResolvedTargetItem,
|
||||
entityIds: string[],
|
||||
primaryEntitiesOnly = true
|
||||
): TargetExplanation => {
|
||||
const single = ({ type, id }: TargetItem): HassServiceTarget => ({
|
||||
[`${type}_id`]: [id],
|
||||
});
|
||||
const includes = items(target).map((item) => {
|
||||
if (item.type === "entity" && item.id === "all") {
|
||||
return {
|
||||
...item,
|
||||
entities: entityIds,
|
||||
referenced_areas: [],
|
||||
referenced_devices: [],
|
||||
};
|
||||
}
|
||||
const { areas, devices, entities } = resolve(
|
||||
single(item),
|
||||
primaryEntitiesOnly
|
||||
);
|
||||
return {
|
||||
...item,
|
||||
entities,
|
||||
referenced_areas: areas,
|
||||
referenced_devices: devices,
|
||||
};
|
||||
});
|
||||
return {
|
||||
includes,
|
||||
excludes: items(target.exclude).map((item) => {
|
||||
const excluded = new Set(resolve(single(item), false).entities);
|
||||
return {
|
||||
...item,
|
||||
removed: includes
|
||||
.map(({ type, id, entities }) => ({
|
||||
type,
|
||||
id,
|
||||
entities: entities.filter((entityId) => excluded.has(entityId)),
|
||||
}))
|
||||
.filter((include) => include.entities.length > 0),
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
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", () => ({}));
|
||||
};
|
||||
@@ -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],
|
||||
|
||||
@@ -158,6 +158,24 @@ const statisticsFunctions: Record<
|
||||
(bucketStart, bucketEnd) =>
|
||||
(20 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
|
||||
),
|
||||
"sensor.gas_kitchen": (_id, start, end, period = "hour") =>
|
||||
generateSumStatistics(
|
||||
start,
|
||||
end,
|
||||
period,
|
||||
100,
|
||||
(bucketStart, bucketEnd) =>
|
||||
(0.1 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
|
||||
),
|
||||
"sensor.gas_heat_pump": (_id, start, end, period = "hour") =>
|
||||
generateSumStatistics(
|
||||
start,
|
||||
end,
|
||||
period,
|
||||
500,
|
||||
(bucketStart, bucketEnd) =>
|
||||
(0.6 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
|
||||
),
|
||||
"sensor.energy_consumption_tarif_1": (
|
||||
_id: string,
|
||||
start: Date,
|
||||
@@ -372,7 +390,9 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
|
||||
const entityState = hass.states[id];
|
||||
const state = entityState ? Number(entityState.state) : 1;
|
||||
statistics[id] =
|
||||
entityState && "last_reset" in entityState.attributes
|
||||
entityState &&
|
||||
(entityState.attributes.state_class === "total" ||
|
||||
entityState.attributes.state_class === "total_increasing")
|
||||
? generateSumStatistics(
|
||||
start,
|
||||
end,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,133 @@
|
||||
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;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -301,6 +301,14 @@ export default tseslint.config(
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Published as native ESM, where package subpath imports such as
|
||||
// "lit/decorators.js" need their extension.
|
||||
files: ["packages/*/src/**/*.ts"],
|
||||
rules: {
|
||||
"import-x/extensions": ["error", "ignorePackages", { ts: "never" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
html,
|
||||
|
||||
@@ -81,6 +81,10 @@ export default [
|
||||
header: "Feedback and status",
|
||||
pages: ["ha-alert", "ha-spinner", "ha-tip", "ha-bar", "ha-gauge"],
|
||||
},
|
||||
{
|
||||
header: "Charts",
|
||||
pages: ["state-history-charts", "statistics-chart"],
|
||||
},
|
||||
{
|
||||
header: "Labels and text",
|
||||
pages: ["ha-badge", "ha-label-badge", "ha-chips", "ha-marquee-text"],
|
||||
|
||||
@@ -1,68 +1,14 @@
|
||||
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,
|
||||
@@ -79,8 +25,8 @@ export const CALENDAR_ENTITIES = [
|
||||
friendly_name: "Family",
|
||||
message: "School run",
|
||||
all_day: false,
|
||||
start_time: at(0, 8),
|
||||
end_time: at(0, 8, 30),
|
||||
start_time: FAMILY_EVENTS[0].start,
|
||||
end_time: FAMILY_EVENTS[0].end,
|
||||
location: "",
|
||||
description: "",
|
||||
supported_features:
|
||||
@@ -96,8 +42,8 @@ export const CALENDAR_ENTITIES = [
|
||||
friendly_name: "Work",
|
||||
message: "Stand-up",
|
||||
all_day: false,
|
||||
start_time: at(0, 9),
|
||||
end_time: at(0, 9, 15),
|
||||
start_time: WORK_EVENTS[0].start,
|
||||
end_time: WORK_EVENTS[0].end,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -145,20 +91,7 @@ export const mockCalendars = (hass: MockHomeAssistant) => {
|
||||
registryEntry("calendar.empty"),
|
||||
registryEntry("calendar.holidays"),
|
||||
]);
|
||||
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;
|
||||
});
|
||||
mockCalendarEvents(hass, (entityId) => EVENTS[entityId]);
|
||||
// Changes made in the event dialogs are accepted but not stored
|
||||
hass.mockWS("calendar/event/create", () => null);
|
||||
hass.mockWS("calendar/event/update", () => null);
|
||||
|
||||
@@ -4,7 +4,7 @@ title: "Our story"
|
||||
|
||||
## Open source home automation that puts local control and privacy first
|
||||
|
||||
Home Assistant is a free and open-source software for home automation that is designed to be the central control system for smart home devices with a focus on local control and privacy. It can be accessed via a web-based user interface, via apps for Android and iOS, or using voice commands via a supported virtual assistant like Google Assistant and Amazon Alexa.
|
||||
Home Assistant is a free and open-source software for home automation that is designed to be the central control system for smart home devices with a focus on local control and privacy. It can be accessed via a web-based user interface, via apps for Android and iOS, or using voice commands via a supported virtual assistant like Google Home and Amazon Alexa.
|
||||
|
||||
IoT devices and services are supported by modular support for controlling proprietary ecosystems if they provide public access via an Open API for third-party integrations and protocols like Bluetooth, MQTT, Zigbee, and Z-Wave, After the Home Assistant software application is installed as a computer appliance it will act as a central control system for home automation. Information from all entities it sees can be used and controlled from within scripts trigger automations using scheduling and "blueprint" subroutines, e.g. for controlling lighting, climate, entertainment systems, and appliances.
|
||||
|
||||
@@ -36,6 +36,6 @@ Durability for the Open Home means that devices are designed and built to keep w
|
||||
|
||||
The project was started as a Python application by Paulus Schoutsen in September 2013 and first published publicly on GitHub in November 2013. In July 2017, a managed operating system called Hass.io was initially introduced to make it easier use to use Home Assistant on single-board computers like the Raspberry Pi series. Its bundled "supervisor" management system allowed users to manage, backup, and update the local installation and introduced the option to extend the functionality of the software with add-ons.
|
||||
|
||||
An optional subscription service was introduced in December 2017 for $5/month to solve the complexities associated with secured remote access, as well as linking to Amazon Alexa and Google Assistant. Nabu Casa, Inc. was formed in September 2018 to take over the subscription service. The company's funding is based solely on revenue from the subscription service. It is used to finance the project's infrastructure and to pay for full-time employees contributing to the project.
|
||||
An optional subscription service was introduced in December 2017 for $5/month to solve the complexities associated with secured remote access, as well as linking to Amazon Alexa and Google Home. Nabu Casa, Inc. was formed in September 2018 to take over the subscription service. The company's funding is based solely on revenue from the subscription service. It is used to finance the project's infrastructure and to pay for full-time employees contributing to the project.
|
||||
|
||||
In January 2020, branding was adjusted to make it easier to refer to different parts of the project. The main piece of software was renamed to Home Assistant Core, while the full suite of software with the embedded operating system and bundled "supervisor" management system was renamed to Home Assistant.
|
||||
@@ -683,8 +683,13 @@ const SCHEMAS: {
|
||||
name: "Unit of measurement",
|
||||
input: {
|
||||
sensor_device_class: {
|
||||
name: "Device class",
|
||||
selector: { device_class: { domain: "sensor" } },
|
||||
name: "Device class of the unit below",
|
||||
selector: {
|
||||
device_class: {
|
||||
domain: "sensor",
|
||||
context: { filter_unit_of_measurement: "unit_linked" },
|
||||
},
|
||||
},
|
||||
},
|
||||
sensor_state_class: {
|
||||
name: "State class",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { customElement, state } from "lit/decorators";
|
||||
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
|
||||
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
|
||||
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
|
||||
import { explainTarget } from "../../../../demo/src/stubs/explain_target";
|
||||
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
|
||||
import { ensureArray } from "../../../../src/common/array/ensure-array";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
@@ -225,6 +226,20 @@ class DemoHaTargetPickerExclusions
|
||||
hass.updateHass({ areas, entities: ENTITY_REGISTRY });
|
||||
hass.mockWS("config/device_registry/list_composite_splits", () => ({}));
|
||||
hass.mockWS("extract_from_target", extractFromTarget);
|
||||
hass.mockWS("explain_target", ({ target }: { target: HassServiceTarget }) =>
|
||||
explainTarget(
|
||||
target,
|
||||
(single) => {
|
||||
const resolved = extractFromTarget({ target: single });
|
||||
return {
|
||||
areas: resolved.referenced_areas,
|
||||
devices: resolved.referenced_devices,
|
||||
entities: resolved.referenced_entities,
|
||||
};
|
||||
},
|
||||
Object.keys(hass.states)
|
||||
)
|
||||
);
|
||||
hass.mockWS("auth/sign_path", (params) => params);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: History charts
|
||||
subtitle: State history as timelines and line charts.
|
||||
---
|
||||
|
||||
`<state-history-charts>` renders the state history of entities: a timeline for entities with text states, and a line chart per unit for numeric ones. It is used by the history panel, the history graph card and the more-info history. The data below is generated, so the charts work without a backend.
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../../src/components/chart/state-history-charts";
|
||||
import "../../../../src/components/ha-card";
|
||||
import type {
|
||||
HistoryResult,
|
||||
LineChartState,
|
||||
TimelineState,
|
||||
} from "../../../../src/data/history";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const HOURS = 48;
|
||||
|
||||
const END = new Date();
|
||||
END.setMinutes(0, 0, 0);
|
||||
const START = new Date(END.getTime() - HOURS * HOUR);
|
||||
|
||||
const ENTITIES = [
|
||||
{
|
||||
entity_id: "sensor.outdoor_temperature",
|
||||
state: "12.4",
|
||||
attributes: {
|
||||
friendly_name: "Outdoor temperature",
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.living_room",
|
||||
state: "on",
|
||||
attributes: { friendly_name: "Living room light" },
|
||||
},
|
||||
{
|
||||
entity_id: "binary_sensor.front_door",
|
||||
state: "off",
|
||||
attributes: { friendly_name: "Front door", device_class: "door" },
|
||||
},
|
||||
];
|
||||
|
||||
const temperatureStates = (): LineChartState[] =>
|
||||
Array.from({ length: HOURS * 2 }, (_, index) => {
|
||||
const time = START.getTime() + (index * HOUR) / 2;
|
||||
const hourOfDay = new Date(time).getHours();
|
||||
const value =
|
||||
10 +
|
||||
4 * Math.sin(((hourOfDay - 9) / 24) * 2 * Math.PI) +
|
||||
0.5 * Math.sin(time / HOUR);
|
||||
return {
|
||||
state: value.toFixed(1),
|
||||
last_changed: time,
|
||||
};
|
||||
});
|
||||
|
||||
const toggleStates = (
|
||||
onHours: [number, number][],
|
||||
onLabel: string,
|
||||
offLabel: string
|
||||
): TimelineState[] => {
|
||||
const on = { state: "on", state_localize: onLabel };
|
||||
const off = { state: "off", state_localize: offLabel };
|
||||
const states: TimelineState[] = [{ ...off, last_changed: START.getTime() }];
|
||||
onHours.forEach(([from, to]) => {
|
||||
states.push(
|
||||
{ ...on, last_changed: START.getTime() + from * HOUR },
|
||||
{ ...off, last_changed: START.getTime() + to * HOUR }
|
||||
);
|
||||
});
|
||||
return states;
|
||||
};
|
||||
|
||||
const HISTORY: HistoryResult = {
|
||||
line: [
|
||||
{
|
||||
unit: "°C",
|
||||
device_class: "temperature",
|
||||
identifier: "temperature",
|
||||
data: [
|
||||
{
|
||||
domain: "sensor",
|
||||
name: "Outdoor temperature",
|
||||
entity_id: "sensor.outdoor_temperature",
|
||||
states: temperatureStates(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
timeline: [
|
||||
{
|
||||
name: "Living room light",
|
||||
entity_id: "light.living_room",
|
||||
data: toggleStates(
|
||||
[
|
||||
[6, 8],
|
||||
[18, 23],
|
||||
[30, 32],
|
||||
[42, 47],
|
||||
],
|
||||
"On",
|
||||
"Off"
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Front door",
|
||||
entity_id: "binary_sensor.front_door",
|
||||
data: toggleStates(
|
||||
[
|
||||
[7.5, 7.6],
|
||||
[17, 17.2],
|
||||
[31.5, 31.6],
|
||||
[41, 41.1],
|
||||
],
|
||||
"Open",
|
||||
"Closed"
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const LIGHT_ONLY: HistoryResult = {
|
||||
line: [],
|
||||
timeline: [HISTORY.timeline[0]],
|
||||
};
|
||||
|
||||
interface Example {
|
||||
title: string;
|
||||
description: string;
|
||||
history: HistoryResult;
|
||||
showNames?: boolean;
|
||||
insideLabels?: boolean;
|
||||
syncCharts?: boolean;
|
||||
}
|
||||
|
||||
const EXAMPLES: Example[] = [
|
||||
{
|
||||
title: "Names in a column",
|
||||
description:
|
||||
"The default layout, like the history graph card: timeline names on the left and a line chart for each unit.",
|
||||
history: HISTORY,
|
||||
},
|
||||
{
|
||||
title: "Names inside the timeline, synced zoom",
|
||||
description:
|
||||
"Like the history panel: names sit under each timeline bar, and zooming one chart zooms all of them.",
|
||||
history: HISTORY,
|
||||
insideLabels: true,
|
||||
syncCharts: true,
|
||||
},
|
||||
{
|
||||
title: "Single entity without names",
|
||||
description:
|
||||
"Like the more-info dialog, where the entity is already known.",
|
||||
history: LIGHT_ONLY,
|
||||
showNames: false,
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-components-state-history-charts")
|
||||
export class DemoStateHistoryCharts extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
hass.addEntities(ENTITIES);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
return EXAMPLES.map(
|
||||
(example) => html`
|
||||
<ha-card header=${example.title}>
|
||||
<div class="card-content">
|
||||
<p>${example.description}</p>
|
||||
<state-history-charts
|
||||
.historyData=${example.history}
|
||||
.startTime=${START}
|
||||
.endTime=${END}
|
||||
.showNames=${example.showNames ?? true}
|
||||
.insideLabels=${example.insideLabels ?? false}
|
||||
.syncCharts=${example.syncCharts ?? false}
|
||||
.clickForMoreInfo=${false}
|
||||
></state-history-charts>
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
max-width: 800px;
|
||||
margin: 24px auto;
|
||||
}
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-state-history-charts": DemoStateHistoryCharts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Statistics chart
|
||||
subtitle: Long-term statistics as a bar or line chart.
|
||||
---
|
||||
|
||||
`<statistics-chart>` renders long-term statistics from the recorder. It is used by the statistics graph card, the more-info history and the statistics developer tools. The data below is generated, so the charts work without a backend.
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../../src/components/chart/statistics-chart";
|
||||
import "../../../../src/components/ha-card";
|
||||
import type {
|
||||
Statistics,
|
||||
StatisticsMetaData,
|
||||
StatisticType,
|
||||
StatisticValue,
|
||||
} from "../../../../src/data/recorder";
|
||||
import { StatisticMeanType } from "../../../../src/data/recorder";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const HOURS = 48;
|
||||
|
||||
const END = new Date();
|
||||
END.setMinutes(0, 0, 0);
|
||||
const START = new Date(END.getTime() - HOURS * HOUR);
|
||||
|
||||
const round = (value: number, digits: number) =>
|
||||
Math.round(value * 10 ** digits) / 10 ** digits;
|
||||
|
||||
const hourlyValues = (
|
||||
compute: (start: number) => Partial<StatisticValue>
|
||||
): StatisticValue[] =>
|
||||
Array.from({ length: HOURS }, (_, hour) => {
|
||||
const start = START.getTime() + hour * HOUR;
|
||||
return { start, end: start + HOUR, ...compute(start) };
|
||||
});
|
||||
|
||||
const energyValues = (dayUse: number, eveningUse: number) => {
|
||||
let sum = 0;
|
||||
return hourlyValues((start) => {
|
||||
const hourOfDay = new Date(start).getHours();
|
||||
const base = hourOfDay < 7 ? 0.1 : hourOfDay < 18 ? dayUse : eveningUse;
|
||||
const change = round(base + 0.2 * Math.abs(Math.sin(start / HOUR)), 2);
|
||||
sum += change;
|
||||
return { change, sum: round(sum, 2) };
|
||||
});
|
||||
};
|
||||
|
||||
const temperatureValues = (average: number, swing: number) =>
|
||||
hourlyValues((start) => {
|
||||
const hourOfDay = new Date(start).getHours();
|
||||
const mean =
|
||||
average +
|
||||
swing * Math.sin(((hourOfDay - 9) / 24) * 2 * Math.PI) +
|
||||
0.3 * Math.sin(start / HOUR);
|
||||
return {
|
||||
mean: round(mean, 1),
|
||||
min: round(mean - 0.4, 1),
|
||||
max: round(mean + 0.4, 1),
|
||||
};
|
||||
});
|
||||
|
||||
const HEAT_PUMP_ID = "sensor.heat_pump_energy";
|
||||
const LIGHTING_ID = "sensor.lighting_energy";
|
||||
const LIVING_ROOM_ID = "sensor.living_room_temperature";
|
||||
const BEDROOM_ID = "sensor.bedroom_temperature";
|
||||
|
||||
const HEAT_PUMP = energyValues(0.4, 1.2);
|
||||
const LIGHTING = energyValues(0.05, 0.3);
|
||||
const LIVING_ROOM = temperatureValues(21, 1.5);
|
||||
const BEDROOM = temperatureValues(18.5, 1);
|
||||
|
||||
const energyMetadata = (id: string, name: string): StatisticsMetaData => ({
|
||||
statistic_id: id,
|
||||
statistics_unit_of_measurement: "kWh",
|
||||
source: "recorder",
|
||||
name,
|
||||
has_sum: true,
|
||||
mean_type: StatisticMeanType.NONE,
|
||||
unit_class: "energy",
|
||||
});
|
||||
|
||||
const temperatureMetadata = (id: string, name: string): StatisticsMetaData => ({
|
||||
statistic_id: id,
|
||||
statistics_unit_of_measurement: "°C",
|
||||
source: "recorder",
|
||||
name,
|
||||
has_sum: false,
|
||||
mean_type: StatisticMeanType.ARITHMETIC,
|
||||
unit_class: "temperature",
|
||||
});
|
||||
|
||||
const METADATA: Record<string, StatisticsMetaData> = {
|
||||
[HEAT_PUMP_ID]: energyMetadata(HEAT_PUMP_ID, "Heat pump"),
|
||||
[LIGHTING_ID]: energyMetadata(LIGHTING_ID, "Lighting"),
|
||||
[LIVING_ROOM_ID]: temperatureMetadata(LIVING_ROOM_ID, "Living room"),
|
||||
[BEDROOM_ID]: temperatureMetadata(BEDROOM_ID, "Bedroom"),
|
||||
};
|
||||
|
||||
const NAMES = Object.fromEntries(
|
||||
Object.values(METADATA).map((meta) => [meta.statistic_id, meta.name!])
|
||||
);
|
||||
|
||||
interface Example {
|
||||
title: string;
|
||||
description: string;
|
||||
statistics: Statistics;
|
||||
statTypes: StatisticType[];
|
||||
chartType: "line" | "line-stack" | "bar" | "bar-stack";
|
||||
unit: string;
|
||||
hideLegend?: boolean;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
const EXAMPLES: Example[] = [
|
||||
{
|
||||
title: "Sum as bars",
|
||||
description:
|
||||
"The change of a sum for each hour, like an energy statistics graph card.",
|
||||
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
|
||||
statTypes: ["change"],
|
||||
chartType: "bar",
|
||||
unit: "kWh",
|
||||
},
|
||||
{
|
||||
title: "Stacked bars",
|
||||
description: "Several sums stacked, to compare what each one adds.",
|
||||
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP, [LIGHTING_ID]: LIGHTING },
|
||||
statTypes: ["change"],
|
||||
chartType: "bar-stack",
|
||||
unit: "kWh",
|
||||
},
|
||||
{
|
||||
title: "Mean, min and max",
|
||||
description: "A measurement with its hourly mean, min and max.",
|
||||
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
|
||||
statTypes: ["mean", "min", "max"],
|
||||
chartType: "line",
|
||||
unit: "°C",
|
||||
},
|
||||
{
|
||||
title: "Several statistics",
|
||||
description: "The mean of several measurements with the same unit.",
|
||||
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM, [BEDROOM_ID]: BEDROOM },
|
||||
statTypes: ["mean"],
|
||||
chartType: "line",
|
||||
unit: "°C",
|
||||
},
|
||||
{
|
||||
title: "Without legend",
|
||||
description:
|
||||
"A single statistic with a fixed height, like in the more-info dialog.",
|
||||
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
|
||||
statTypes: ["mean", "min", "max"],
|
||||
chartType: "line",
|
||||
unit: "°C",
|
||||
hideLegend: true,
|
||||
height: "240px",
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-components-statistics-chart")
|
||||
export class DemoStatisticsChart extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
return EXAMPLES.map(
|
||||
(example) => html`
|
||||
<ha-card header=${example.title}>
|
||||
<div class="card-content">
|
||||
<p>${example.description}</p>
|
||||
<statistics-chart
|
||||
.statisticsData=${example.statistics}
|
||||
.metadata=${METADATA}
|
||||
.names=${NAMES}
|
||||
.statTypes=${example.statTypes}
|
||||
.chartType=${example.chartType}
|
||||
period="hour"
|
||||
.unit=${example.unit}
|
||||
.startTime=${START}
|
||||
.endTime=${END}
|
||||
.hideLegend=${example.hideLegend ?? false}
|
||||
.height=${example.height}
|
||||
.clickForMoreInfo=${false}
|
||||
></statistics-chart>
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
max-width: 800px;
|
||||
margin: 24px auto;
|
||||
}
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-statistics-chart": DemoStatisticsChart;
|
||||
}
|
||||
}
|
||||
@@ -201,6 +201,50 @@ const ENTITIES = [
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.on_off_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Bathroom fan",
|
||||
device_class: "fan",
|
||||
percentage: 100,
|
||||
percentage_step: 100,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.six_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Kitchen fan",
|
||||
device_class: "fan",
|
||||
percentage: 50,
|
||||
percentage_step: 100 / 6,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.ten_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Living room fan",
|
||||
device_class: "fan",
|
||||
percentage: 70,
|
||||
percentage_step: 10,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.eleven_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Office fan",
|
||||
device_class: "fan",
|
||||
percentage: 45,
|
||||
percentage_step: 100 / 11,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const CONFIGS = [
|
||||
@@ -410,6 +454,55 @@ const CONFIGS = [
|
||||
features: [{ type: "fan-speed" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (5 speeds, slider style)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.five_speed_fan",
|
||||
features: [{ type: "fan-speed", style: "slider" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (off/on, slider style falls back to buttons)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.on_off_fan",
|
||||
features: [{ type: "fan-speed", style: "slider" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (6 speeds, slider by default)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.six_speed_fan",
|
||||
features: [{ type: "fan-speed" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (6 speeds, buttons style)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.six_speed_fan",
|
||||
features: [{ type: "fan-speed", style: "buttons" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (10 speeds, buttons style)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.ten_speed_fan",
|
||||
features: [{ type: "fan-speed", style: "buttons" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading:
|
||||
"Fan speed feature (11 speeds, buttons style falls back to slider)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.eleven_speed_fan",
|
||||
features: [{ type: "fan-speed", style: "buttons" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (half width)",
|
||||
config: {
|
||||
@@ -430,6 +523,27 @@ const CONFIGS = [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading:
|
||||
"Fan speed feature (half width, buttons fall back to a slider when they do not fit)",
|
||||
config: {
|
||||
type: "grid",
|
||||
columns: 2,
|
||||
square: false,
|
||||
cards: [
|
||||
{
|
||||
type: "tile",
|
||||
entity: "fan.six_speed_fan",
|
||||
features: [{ type: "fan-speed", style: "buttons" }],
|
||||
},
|
||||
{
|
||||
type: "tile",
|
||||
entity: "fan.ten_speed_fan",
|
||||
features: [{ type: "fan-speed", style: "buttons" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan oscillate feature",
|
||||
config: {
|
||||
|
||||
@@ -121,7 +121,7 @@ const brandEntries: ConfigEntryExtended[] = (
|
||||
["tuya", "Tuya", "setup_error"],
|
||||
["unifi", "UniFi Network", "not_loaded"],
|
||||
["met", "Meteorologisk institutt (Met.no)", "setup_error"],
|
||||
["google_assistant", "Google Assistant", "not_loaded"],
|
||||
["google_assistant", "Google Home", "not_loaded"],
|
||||
["homekit_controller", "HomeKit Device", "setup_retry"],
|
||||
] as [string, string, ConfigEntryExtended["state"]][]
|
||||
).map(([domain, name, entryState]) =>
|
||||
|
||||
+28
-27
@@ -13,7 +13,7 @@
|
||||
"lint:prettier": "prettier . --cache --check",
|
||||
"format:prettier": "prettier . --cache --write",
|
||||
"lint:types": "node ./node_modules/@typescript/native/bin/tsc && node ./node_modules/@typescript/native/bin/tsc -p .github/scripts",
|
||||
"lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"",
|
||||
"lint:lit": "lit-analyzer \"{.,*,packages/*}/src/**/*.ts\"",
|
||||
"lint:element-imports": "eslint \"**/src/**/*.{js,ts,html}\" --ignore-pattern=.gitignore --rule '{\"ha/no-unused-element-import\": \"warn\"}'",
|
||||
"lint:licenses": "node --no-deprecation script/check-licenses",
|
||||
"lint": "pnpm run lint:eslint && pnpm run lint:prettier && pnpm run lint:types && pnpm run lint:lit",
|
||||
@@ -40,7 +40,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "8.0.10",
|
||||
"@babel/runtime": "8.1.0",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.4",
|
||||
"@codemirror/commands": "6.11.1",
|
||||
@@ -54,17 +54,17 @@
|
||||
"@codemirror/view": "6.43.15",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@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",
|
||||
"@formatjs/intl-datetimeformat": "8.0.2",
|
||||
"@formatjs/intl-displaynames": "8.0.1",
|
||||
"@formatjs/intl-durationformat": "0.12.3",
|
||||
"@formatjs/intl-getcanonicallocales": "3.3.0",
|
||||
"@formatjs/intl-listformat": "9.0.1",
|
||||
"@formatjs/intl-locale": "5.4.2",
|
||||
"@formatjs/intl-numberformat": "10.0.2",
|
||||
"@formatjs/intl-pluralrules": "7.0.2",
|
||||
"@formatjs/intl-relativetimeformat": "13.0.1",
|
||||
"@home-assistant/webawesome": "3.14.0-ha.2",
|
||||
"@lezer/common": "1.5.4",
|
||||
"@lezer/common": "1.5.5",
|
||||
"@lezer/highlight": "1.2.5",
|
||||
"@lit-labs/motion": "1.1.0",
|
||||
"@lit-labs/observers": "2.1.0",
|
||||
@@ -78,9 +78,9 @@
|
||||
"@mdi/svg": "7.4.47",
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@tsparticles/engine": "4.5.0",
|
||||
"@tsparticles/preset-links": "4.5.0",
|
||||
"@versatiles/style": "7.1.0",
|
||||
"@tsparticles/engine": "4.5.1",
|
||||
"@tsparticles/preset-links": "4.5.1",
|
||||
"@versatiles/style": "8.1.1",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.202.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
@@ -89,7 +89,7 @@
|
||||
"color-name": "2.1.1",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.51.0",
|
||||
"cropperjs": "2.3.1",
|
||||
"cropperjs": "1.7.0",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.4.0",
|
||||
"decimal.js": "10.6.0",
|
||||
@@ -98,7 +98,7 @@
|
||||
"echarts": "6.1.0",
|
||||
"echarts-extension-chart2music": "0.1.1",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fullcalendar": "7.1.3",
|
||||
"fullcalendar": "7.1.4",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.7.4",
|
||||
"home-assistant-js-websocket": "9.8.1",
|
||||
@@ -110,7 +110,7 @@
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"maplibre-gl": "6.16.0",
|
||||
"marked": "18.1.2",
|
||||
"marked": "18.1.3",
|
||||
"memoize-one": "6.0.0",
|
||||
"nanoid": "6.0.2",
|
||||
"node-vibrant": "4.0.4",
|
||||
@@ -123,7 +123,7 @@
|
||||
"sortablejs": "1.15.7",
|
||||
"stacktrace-js": "2.0.2",
|
||||
"superstruct": "2.0.2",
|
||||
"temporal-polyfill": "1.0.6",
|
||||
"temporal-polyfill": "1.0.7",
|
||||
"tinykeys": "4.0.1",
|
||||
"weekstart": "2.0.0",
|
||||
"workbox-cacheable-response": "7.4.1",
|
||||
@@ -137,23 +137,24 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ampproject/remapping": "2.3.0",
|
||||
"@babel/core": "8.0.10",
|
||||
"@babel/core": "8.1.0",
|
||||
"@babel/helper-compilation-targets": "8.0.7",
|
||||
"@babel/helper-define-polyfill-provider": "1.0.0",
|
||||
"@babel/plugin-transform-class-properties": "8.0.1",
|
||||
"@babel/plugin-transform-private-methods": "8.0.1",
|
||||
"@babel/plugin-transform-runtime": "8.0.6",
|
||||
"@babel/preset-env": "8.0.7",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.3",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.23.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@home-assistant/components": "workspace:*",
|
||||
"@html-eslint/eslint-plugin": "0.67.0",
|
||||
"@lokalise/node-api": "17.0.1",
|
||||
"@octokit/auth-oauth-device": "8.0.5",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.64.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.5",
|
||||
"@rspack/core": "2.3.0",
|
||||
"@rspack/core": "2.3.1",
|
||||
"@rspack/dev-server": "2.2.2",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "7.0.1",
|
||||
@@ -170,14 +171,14 @@
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@typescript/native": "npm:[email protected]",
|
||||
"@vitest/coverage-v8": "5.0.5",
|
||||
"@vitest/coverage-v8": "5.1.0",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist": "4.29.5",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"core-js-compat": "3.51.0",
|
||||
"del": "9.0.1",
|
||||
"eslint": "10.13.0",
|
||||
"eslint": "10.14.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
@@ -189,7 +190,7 @@
|
||||
"fs-extra": "11.4.1",
|
||||
"generate-license-file": "4.2.5",
|
||||
"glob": "14.0.3",
|
||||
"globals": "17.13.0",
|
||||
"globals": "17.14.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
@@ -217,12 +218,12 @@
|
||||
"typescript-eslint": "8.72.1",
|
||||
"vite": "8.3.5",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.0.5",
|
||||
"vitest": "5.1.0",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "7.4.1"
|
||||
},
|
||||
"packageManager": "[email protected]6.1",
|
||||
"packageManager": "[email protected]7.0",
|
||||
"volta": {
|
||||
"node": "24.22.2"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# @home-assistant/components
|
||||
|
||||
Home Assistant Components (`hac-*`): framework-agnostic web components built
|
||||
with [Lit](https://lit.dev).
|
||||
|
||||
> Work in progress, not published yet.
|
||||
|
||||
## Registration
|
||||
|
||||
Every element is imported on its own, in one of two ways:
|
||||
|
||||
| Import | Registers in | For |
|
||||
| --------------------------------------------- | -------------------- | ----------------------------------------------------------- |
|
||||
| `@home-assistant/components/<element>` | Global registry | Home Assistant itself |
|
||||
| `@home-assistant/components/<element>/scoped` | Your scoped registry | Custom cards and other code that runs inside Home Assistant |
|
||||
|
||||
`<element>` is the tag name without the `hac-` prefix, like `card` for
|
||||
`hac-card`.
|
||||
|
||||
Both use the copy bundled with your code. A scoped copy is isolated from the
|
||||
one Home Assistant registers globally, so a custom card keeps the version it
|
||||
was built with.
|
||||
|
||||
Global registration skips tag names that are already registered, so the first
|
||||
registration wins and nothing throws.
|
||||
|
||||
### Global
|
||||
|
||||
```ts
|
||||
import "@home-assistant/components/card";
|
||||
```
|
||||
|
||||
### Scoped
|
||||
|
||||
Pass the component's elements to `ScopedElementsMixin` for Lit, or to
|
||||
`createScopedElementRegistry` for plain custom elements. Forgetting the
|
||||
elements is a TypeScript error, and a runtime error in plain JavaScript.
|
||||
|
||||
Lit:
|
||||
|
||||
```ts
|
||||
import { LitElement, html } from "lit";
|
||||
import { ScopedElementsMixin } from "@home-assistant/components/scoped";
|
||||
import { hacCard } from "@home-assistant/components/card/scoped";
|
||||
|
||||
class MyCard extends ScopedElementsMixin(LitElement, [hacCard]) {
|
||||
render() {
|
||||
return html`<hac-card>…</hac-card>`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Plain custom elements:
|
||||
|
||||
```js
|
||||
import {
|
||||
attachScopedShadow,
|
||||
createScopedElement,
|
||||
createScopedElementRegistry,
|
||||
} from "@home-assistant/components/scoped";
|
||||
import { hacCard } from "@home-assistant/components/card/scoped";
|
||||
|
||||
// Create the registry once, outside the class: all instances of the card share it
|
||||
const registry = createScopedElementRegistry([hacCard]);
|
||||
|
||||
class MyCard extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
const root = attachScopedShadow(this, { mode: "open" }, registry);
|
||||
root.innerHTML = "<hac-card>…</hac-card>";
|
||||
// or: root.append(createScopedElement(root, "hac-card"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`innerHTML`, `insertAdjacentHTML` and `createScopedElement` use the scoped
|
||||
registry. `document.createElement` uses the global one, so avoid it for
|
||||
scoped elements.
|
||||
|
||||
Scoped registries need native support (Chrome 146+, Safari 26) or
|
||||
`@webcomponents/scoped-custom-element-registry`, which Home Assistant loads.
|
||||
Without either, elements are registered globally instead, and an already
|
||||
registered tag name keeps its existing class.
|
||||
|
||||
Only elements rendered inside the shadow root use the scoped registry. Light
|
||||
DOM content, such as slotted elements, uses the registry of the tree it is
|
||||
written in.
|
||||
|
||||
`lit` is a peer dependency.
|
||||
|
||||
### TypeScript
|
||||
|
||||
Each element's entry points declare its tag in `HTMLElementTagNameMap`, so
|
||||
`document.querySelector("hac-card")` and templates are typed once the element
|
||||
is imported. For the class itself, import its type from the `scoped` entry
|
||||
point; `import type` loads nothing at runtime:
|
||||
|
||||
```ts
|
||||
import type { HacCard } from "@home-assistant/components/card/scoped";
|
||||
```
|
||||
|
||||
The package has no root entry point: each element and the scoped helpers
|
||||
(`@home-assistant/components/scoped`) have their own.
|
||||
|
||||
## Theming
|
||||
|
||||
Each component exposes its own `--hac-*` custom properties, documented in its
|
||||
source, which take precedence. Next come the theme variables of the existing
|
||||
Home Assistant element it replaces, so current themes keep working: `hac-card`
|
||||
reads the variables of `ha-card` (`--ha-card-background`,
|
||||
`--card-background-color`, `--ha-card-border-radius`, …). Last come the
|
||||
semantic color tokens (`--ha-color-*`, like `--ha-color-surface-default`) and
|
||||
their light values, so the components also render outside Home Assistant.
|
||||
|
||||
The legacy theme variables are a temporary bridge for this experiment, not the
|
||||
target design. Home Assistant always defines some of them, like
|
||||
`--card-background-color`, so inside Home Assistant they currently win over
|
||||
the semantic tokens. Once themes move to the semantic tokens, the legacy
|
||||
variables will be removed from the fallbacks.
|
||||
|
||||
## Structure
|
||||
|
||||
Each element has its own directory, named like its import path:
|
||||
|
||||
```
|
||||
src/components/card/ <hac-card> -> @home-assistant/components/card
|
||||
hac-card.ts class, does not register itself
|
||||
info.ts tag name and version
|
||||
global.ts registers globally
|
||||
scoped.ts element for a scoped registry -> …/card/scoped
|
||||
```
|
||||
|
||||
Shared code lives in `src/utils/`: `registration/` for defining elements
|
||||
(global and scoped) and `element/` for helpers that components use
|
||||
themselves, like slot checks.
|
||||
|
||||
Every element has its own version in `info.ts`.
|
||||
|
||||
Entry points are mapped with subpath patterns in `package.json`, so a new
|
||||
element directory needs no `package.json` change. Only `global.ts` files have
|
||||
side effects, which `sideEffects` relies on for tree shaking.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm --filter @home-assistant/components build # ESM, type declarations and custom-elements.json to dist/
|
||||
pnpm --filter @home-assistant/components dev # rebuild on change
|
||||
```
|
||||
|
||||
Inside this repository the app uses the sources directly through
|
||||
the `ha-source` export condition, so no build is needed during development.
|
||||
The package is built with [Rslib](https://rslib.rs) in bundleless mode: every
|
||||
source file becomes its own ES module. The build then generates a
|
||||
[custom elements manifest](https://custom-elements-manifest.open-wc.org) from
|
||||
the JSDoc of each element, so document new slots, parts, CSS properties and
|
||||
events there.
|
||||
@@ -0,0 +1,84 @@
|
||||
// Generates dist/custom-elements.json from the JSDoc of the hac elements, for
|
||||
// editors and documentation tools. Only hac-*.ts files declare elements; the
|
||||
// entry points just re-export them.
|
||||
|
||||
const forEachDeclaration = (customElementsManifest, callback) => {
|
||||
for (const module of customElementsManifest.modules) {
|
||||
for (const declaration of module.declarations ?? []) {
|
||||
callback(declaration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The analyzer leaves the readme empty. Paths in the manifest are relative to
|
||||
// the package root, like the module paths.
|
||||
const packageReadme = () => ({
|
||||
name: "package-readme",
|
||||
packageLinkPhase({ customElementsManifest }) {
|
||||
customElementsManifest.readme = "README.md";
|
||||
},
|
||||
});
|
||||
|
||||
// Elements are documented with @summary, which the analyzer only puts into
|
||||
// `summary`. Most tools show `description`, so fall back to the summary.
|
||||
const summaryAsDescription = () => ({
|
||||
name: "summary-as-description",
|
||||
packageLinkPhase({ customElementsManifest }) {
|
||||
forEachDeclaration(customElementsManifest, (declaration) => {
|
||||
if (!declaration.description && declaration.summary) {
|
||||
declaration.description = declaration.summary;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Private and protected members are internal, so keep them out of the
|
||||
// documented API.
|
||||
const publicApiOnly = () => ({
|
||||
name: "public-api-only",
|
||||
packageLinkPhase({ customElementsManifest }) {
|
||||
forEachDeclaration(customElementsManifest, (declaration) => {
|
||||
declaration.members = declaration.members?.filter(
|
||||
(member) => !member.privacy || member.privacy === "public"
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const distPaths = () => {
|
||||
const toDist = (value) => value.replace(/^src\/(.+)\.ts$/, "dist/$1.js");
|
||||
const rewrite = (node) => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(rewrite);
|
||||
return;
|
||||
}
|
||||
if (!node || typeof node !== "object") {
|
||||
return;
|
||||
}
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if ((key === "path" || key === "module") && typeof value === "string") {
|
||||
node[key] = toDist(value);
|
||||
} else {
|
||||
rewrite(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
return {
|
||||
name: "dist-paths",
|
||||
packageLinkPhase({ customElementsManifest }) {
|
||||
rewrite(customElementsManifest.modules);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
globs: ["src/components/**/hac-*.ts"],
|
||||
outdir: "dist",
|
||||
litelement: true,
|
||||
plugins: [
|
||||
packageReadme(),
|
||||
summaryAsDescription(),
|
||||
publicApiOnly(),
|
||||
distPaths(),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "@home-assistant/components",
|
||||
"version": "0.0.0",
|
||||
"description": "Home Assistant web components (hac-* elements) built with Lit",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/home-assistant/frontend.git",
|
||||
"directory": "packages/components"
|
||||
},
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
"./dist/components/**/global.js",
|
||||
"./src/components/**/global.ts"
|
||||
],
|
||||
"customElements": "dist/custom-elements.json",
|
||||
"exports": {
|
||||
"./scoped": {
|
||||
"ha-source": "./src/scoped.ts",
|
||||
"types": "./dist/scoped.d.ts",
|
||||
"import": "./dist/scoped.js"
|
||||
},
|
||||
"./package.json": "./package.json",
|
||||
"./*/scoped": {
|
||||
"ha-source": "./src/components/*/scoped.ts",
|
||||
"types": "./dist/components/*/scoped.d.ts",
|
||||
"import": "./dist/components/*/scoped.js"
|
||||
},
|
||||
"./*/info": {
|
||||
"ha-source": "./src/components/*/info.ts",
|
||||
"types": "./dist/components/*/info.d.ts",
|
||||
"import": "./dist/components/*/info.js"
|
||||
},
|
||||
"./*": {
|
||||
"ha-source": "./src/components/*/global.ts",
|
||||
"types": "./dist/components/*/global.d.ts",
|
||||
"import": "./dist/components/*/global.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rslib build && cem analyze",
|
||||
"dev": "rslib build --watch",
|
||||
"prepublishOnly": "pnpm run build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"lit": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@custom-elements-manifest/analyzer": "0.11.0",
|
||||
"@rslib/core": "1.0.5",
|
||||
"lit": "3.3.3",
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from "@rslib/core";
|
||||
|
||||
// Bundleless build: every source file becomes its own ESM file, so consumers'
|
||||
// bundlers only include the entry points they import. Only global.ts files
|
||||
// register elements; everything else is free of side effects.
|
||||
export default defineConfig({
|
||||
source: {
|
||||
entry: { index: ["./src/**/*.ts"] },
|
||||
decorators: { version: "legacy" },
|
||||
tsconfigPath: "./tsconfig.json",
|
||||
},
|
||||
tools: {
|
||||
swc: { jsc: { transform: { decoratorMetadata: false } } },
|
||||
},
|
||||
lib: [
|
||||
{
|
||||
format: "esm",
|
||||
bundle: false,
|
||||
syntax: "es2021",
|
||||
dts: true,
|
||||
output: { target: "web", distPath: { root: "./dist" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { define } from "../../utils/registration/define";
|
||||
import { HacCard } from "./hac-card";
|
||||
import { TAG_NAME } from "./info";
|
||||
|
||||
// Registers <hac-card> in the global registry, as used by Home Assistant
|
||||
define(TAG_NAME, HacCard);
|
||||
|
||||
export { HacCard };
|
||||
@@ -0,0 +1,165 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import {
|
||||
hasAssignedContent,
|
||||
hasAssignedElements,
|
||||
} from "../../utils/element/slot";
|
||||
|
||||
/**
|
||||
* @element hac-card
|
||||
*
|
||||
* @summary
|
||||
* Card that groups related content, with an optional header and footer.
|
||||
*
|
||||
* @slot header - Header of the card. Its spacing replaces the top padding of
|
||||
* the content.
|
||||
* @slot - Content of the card. Its padding is omitted when empty.
|
||||
* @slot footer - Footer of the card. Hidden when empty.
|
||||
*
|
||||
* @csspart header - Wrapper of the header slot.
|
||||
* @csspart content - Wrapper of the default slot.
|
||||
* @csspart footer - Wrapper of the footer slot.
|
||||
*
|
||||
* @cssprop --hac-card-background - Background of the card.
|
||||
* @cssprop --hac-card-color - Text color of the card.
|
||||
* @cssprop --hac-card-border-color - Border color of the card and the footer divider.
|
||||
* @cssprop --hac-card-border-width - Border width of the card.
|
||||
* @cssprop --hac-card-border-radius - Border radius of the card.
|
||||
* @cssprop --hac-card-box-shadow - Box shadow of the card.
|
||||
* @cssprop --hac-card-content-padding - Padding of the content.
|
||||
* @cssprop --hac-card-footer-padding - Padding of the footer.
|
||||
*/
|
||||
export class HacCard extends LitElement {
|
||||
/**
|
||||
* Visual style of the card. `outlined` draws a border without a shadow.
|
||||
* `raised` hides the border and lifts the card with a shadow instead.
|
||||
*/
|
||||
@property({ reflect: true }) public appearance: "outlined" | "raised" =
|
||||
"outlined";
|
||||
|
||||
@state() private _hasHeader = false;
|
||||
|
||||
@state() private _hasFooter = false;
|
||||
|
||||
@state() private _hasContent = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div class="header" part="header" ?hidden=${!this._hasHeader}>
|
||||
<slot name="header" @slotchange=${this._headerChanged}></slot>
|
||||
</div>
|
||||
<div
|
||||
class="content ${this._hasHeader ? "below-header" : ""}"
|
||||
part="content"
|
||||
?hidden=${!this._hasContent}
|
||||
>
|
||||
<slot @slotchange=${this._contentChanged}></slot>
|
||||
</div>
|
||||
<div class="footer" part="footer" ?hidden=${!this._hasFooter}>
|
||||
<slot name="footer" @slotchange=${this._footerChanged}></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _headerChanged(ev: Event) {
|
||||
this._hasHeader = hasAssignedElements(ev);
|
||||
}
|
||||
|
||||
private _contentChanged(ev: Event) {
|
||||
this._hasContent = hasAssignedContent(ev);
|
||||
}
|
||||
|
||||
private _footerChanged(ev: Event) {
|
||||
this._hasFooter = hasAssignedElements(ev);
|
||||
}
|
||||
|
||||
/*
|
||||
* Fallback order: --hac-card-*, then the theme variables of ha-card
|
||||
* (--ha-card-*, --card-background-color, --divider-color,
|
||||
* --primary-text-color) so existing themes keep working, then the semantic
|
||||
* tokens and their light values for use outside Home Assistant.
|
||||
* The ha-card variables are temporary, see "Theming" in the README.
|
||||
*/
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
background: var(
|
||||
--hac-card-background,
|
||||
var(
|
||||
--ha-card-background,
|
||||
var(--card-background-color, var(--ha-color-surface-default, #fff))
|
||||
)
|
||||
);
|
||||
backdrop-filter: var(--ha-card-backdrop-filter, none);
|
||||
color: var(
|
||||
--hac-card-color,
|
||||
var(--primary-text-color, var(--ha-color-on-surface-default, #141414))
|
||||
);
|
||||
border: var(--hac-card-border-width, var(--ha-card-border-width, 1px))
|
||||
solid
|
||||
var(
|
||||
--hac-card-border-color,
|
||||
var(
|
||||
--ha-card-border-color,
|
||||
var(--divider-color, var(--ha-color-border-neutral-quiet, #e6e6e6))
|
||||
)
|
||||
);
|
||||
border-radius: var(
|
||||
--hac-card-border-radius,
|
||||
var(--ha-card-border-radius, var(--ha-border-radius-lg, 12px))
|
||||
);
|
||||
box-shadow: var(--hac-card-box-shadow, var(--ha-card-box-shadow, none));
|
||||
}
|
||||
|
||||
:host([appearance="raised"]) {
|
||||
border-color: transparent;
|
||||
box-shadow: var(
|
||||
--hac-card-box-shadow,
|
||||
var(
|
||||
--ha-card-box-shadow,
|
||||
var(
|
||||
--ha-box-shadow-s,
|
||||
0 1px 2px 0 rgba(0, 0, 0, 0.08),
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.12)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--hac-card-content-padding, var(--ha-space-4, 16px));
|
||||
}
|
||||
|
||||
/* The header already provides the spacing above the content */
|
||||
.content.below-header {
|
||||
padding-block-start: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2, 8px);
|
||||
padding: var(--hac-card-footer-padding, var(--ha-space-2, 8px));
|
||||
border-block-start: 1px solid
|
||||
var(
|
||||
--hac-card-border-color,
|
||||
var(--divider-color, var(--ha-color-border-neutral-quiet, #e6e6e6))
|
||||
);
|
||||
}
|
||||
|
||||
.header[hidden],
|
||||
.content[hidden],
|
||||
.footer[hidden] {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hac-card": HacCard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Tag name of the element. */
|
||||
export const TAG_NAME = "hac-card";
|
||||
|
||||
/** Version of <hac-card>. */
|
||||
export const VERSION = "0.1.0";
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ElementDefinition } from "../../utils/registration/definition";
|
||||
import { HacCard } from "./hac-card";
|
||||
import { TAG_NAME } from "./info";
|
||||
|
||||
export { HacCard };
|
||||
|
||||
/**
|
||||
* The bundled <hac-card> for a scoped registry:
|
||||
* `ScopedElementsMixin(LitElement, [hacCard])` or
|
||||
* `createScopedElementRegistry([hacCard])`.
|
||||
*/
|
||||
export const hacCard: ElementDefinition<typeof HacCard> = {
|
||||
tagName: TAG_NAME,
|
||||
element: HacCard,
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { LitElement } from "lit";
|
||||
import { define } from "./utils/registration/define";
|
||||
import type { ElementDefinition } from "./utils/registration/definition";
|
||||
import {
|
||||
createScopedRegistry,
|
||||
scopedCreationScope,
|
||||
scopedRegistrySupport,
|
||||
scopedShadowRootInit,
|
||||
} from "./utils/registration/scoped-registry";
|
||||
|
||||
export type { ElementDefinition } from "./utils/registration/definition";
|
||||
|
||||
/**
|
||||
* Creates a registry with the given elements, for `attachScopedShadow`:
|
||||
* definitions from `scoped` entry points, e.g. `[hacCard]`.
|
||||
* Create it once, outside the class, so all instances of a card share it.
|
||||
*
|
||||
* Without scoped registry support (native or polyfill), the elements are
|
||||
* registered in the global registry instead, unless their tag name is already
|
||||
* taken there, and the global registry is returned.
|
||||
*/
|
||||
export const createScopedElementRegistry = (
|
||||
elements: readonly ElementDefinition[]
|
||||
): CustomElementRegistry => {
|
||||
// Checked as unknown: Array.isArray would narrow the list to any[]
|
||||
if (!Array.isArray(elements as unknown)) {
|
||||
throw new TypeError(
|
||||
"@home-assistant/components: pass the elements to register, e.g. [hacCard]"
|
||||
);
|
||||
}
|
||||
const registry = createScopedRegistry() ?? customElements;
|
||||
for (const { tagName, element } of elements) {
|
||||
define(tagName, element, registry);
|
||||
}
|
||||
return registry;
|
||||
};
|
||||
|
||||
const shadowRootInit = (
|
||||
init: ShadowRootInit,
|
||||
registry: CustomElementRegistry
|
||||
): ShadowRootInit =>
|
||||
registry === customElements ? init : scopedShadowRootInit(init, registry);
|
||||
|
||||
const registries = new WeakMap<ShadowRoot, CustomElementRegistry>();
|
||||
|
||||
/**
|
||||
* Attaches a shadow root that uses `registry` for custom elements in it.
|
||||
*
|
||||
* Content added with `innerHTML` or `insertAdjacentHTML` uses the registry.
|
||||
* Create single elements with `createScopedElement`; `document.createElement`
|
||||
* would use the global registry.
|
||||
*
|
||||
* Light DOM content, like slotted elements, keeps using the registry of the
|
||||
* tree it is written in.
|
||||
*/
|
||||
export const attachScopedShadow = (
|
||||
host: HTMLElement,
|
||||
init: ShadowRootInit,
|
||||
registry: CustomElementRegistry
|
||||
): ShadowRoot => {
|
||||
const root = host.attachShadow(shadowRootInit(init, registry));
|
||||
registries.set(root, registry);
|
||||
return root;
|
||||
};
|
||||
|
||||
/** Creates an element from the registry of a shadow root. */
|
||||
export const createScopedElement = <K extends keyof HTMLElementTagNameMap>(
|
||||
root: ShadowRoot,
|
||||
tagName: K
|
||||
): HTMLElementTagNameMap[K] => {
|
||||
const registry = registries.get(root);
|
||||
if (!registry || registry === customElements) {
|
||||
return document.createElement(tagName);
|
||||
}
|
||||
if (scopedRegistrySupport() === "polyfill") {
|
||||
// The polyfill adds createElement to shadow roots
|
||||
return (
|
||||
root as unknown as {
|
||||
createElement(tagName: K): HTMLElementTagNameMap[K];
|
||||
}
|
||||
).createElement(tagName);
|
||||
}
|
||||
return (
|
||||
document.createElement as (
|
||||
tagName: K,
|
||||
options: { customElementRegistry: CustomElementRegistry }
|
||||
) => HTMLElementTagNameMap[K]
|
||||
)(tagName, { customElementRegistry: registry });
|
||||
};
|
||||
|
||||
type LitElementConstructor = abstract new (...args: any[]) => LitElement;
|
||||
|
||||
/**
|
||||
* Renders a Lit element into a shadow root with its own custom element
|
||||
* registry, containing `elements`:
|
||||
*
|
||||
* ```ts
|
||||
* class MyCard extends ScopedElementsMixin(LitElement, [hacCard]) {}
|
||||
* ```
|
||||
*
|
||||
* See `createScopedElementRegistry` and `attachScopedShadow` for the details.
|
||||
*/
|
||||
export const ScopedElementsMixin = <T extends LitElementConstructor>(
|
||||
superClass: T,
|
||||
elements: readonly ElementDefinition[]
|
||||
) => {
|
||||
// Created on first use, so defining the class has no side effects
|
||||
let registry: CustomElementRegistry | undefined;
|
||||
const getRegistry = () => {
|
||||
registry ??= createScopedElementRegistry(elements);
|
||||
return registry;
|
||||
};
|
||||
|
||||
abstract class ScopedElementsHost extends superClass {
|
||||
public attachShadow(init: ShadowRootInit): ShadowRoot {
|
||||
const root = super.attachShadow(shadowRootInit(init, getRegistry()));
|
||||
registries.set(root, getRegistry());
|
||||
return root;
|
||||
}
|
||||
|
||||
protected createRenderRoot() {
|
||||
const root = super.createRenderRoot();
|
||||
const scoped = getRegistry();
|
||||
if (scoped !== customElements && root instanceof ShadowRoot) {
|
||||
this.renderOptions.creationScope = scopedCreationScope(root, scoped);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
}
|
||||
// Typed as the base class: the overrides add no public API
|
||||
return ScopedElementsHost as unknown as T;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Whether the slot of a `slotchange` event has elements assigned. */
|
||||
export const hasAssignedElements = (ev: Event): boolean =>
|
||||
(ev.target as HTMLSlotElement).assignedElements().length > 0;
|
||||
|
||||
/**
|
||||
* Whether the slot of a `slotchange` event has content assigned: elements or
|
||||
* text. Whitespace between tags is not content.
|
||||
*/
|
||||
export const hasAssignedContent = (ev: Event): boolean =>
|
||||
(ev.target as HTMLSlotElement)
|
||||
.assignedNodes()
|
||||
.some(
|
||||
(node) =>
|
||||
node.nodeType === Node.ELEMENT_NODE || Boolean(node.textContent?.trim())
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Registers a custom element unless the tag name is already taken in the
|
||||
* registry.
|
||||
*
|
||||
* Home Assistant and custom cards can each ship a copy of this library, so
|
||||
* registering the same tag name twice must not throw. The first registration
|
||||
* wins.
|
||||
*/
|
||||
export const define = (
|
||||
tagName: string,
|
||||
elementClass: CustomElementConstructor,
|
||||
registry: CustomElementRegistry = customElements
|
||||
): void => {
|
||||
if (!registry.get(tagName)) {
|
||||
registry.define(tagName, elementClass);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* An element to register: its tag name and its class. The `scoped` entry
|
||||
* point of an element exports one, e.g. `hacCard`.
|
||||
*/
|
||||
export interface ElementDefinition<
|
||||
E extends CustomElementConstructor = CustomElementConstructor,
|
||||
> {
|
||||
readonly tagName: string;
|
||||
readonly element: E;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Scoped custom element registries come in two flavors: the standard API
|
||||
* (Chrome 146+, Safari 26) and the older API of
|
||||
* `@webcomponents/scoped-custom-element-registry`, which Home Assistant loads
|
||||
* and which replaces the native implementation when present.
|
||||
*/
|
||||
export type ScopedRegistrySupport = "polyfill" | "native" | "none";
|
||||
|
||||
let support: ScopedRegistrySupport | undefined;
|
||||
|
||||
export const scopedRegistrySupport = (): ScopedRegistrySupport => {
|
||||
if (support) {
|
||||
return support;
|
||||
}
|
||||
if ("CustomElementRegistryPolyfill" in globalThis) {
|
||||
support = "polyfill";
|
||||
} else {
|
||||
try {
|
||||
new CustomElementRegistry();
|
||||
support = "native";
|
||||
} catch (_err) {
|
||||
support = "none";
|
||||
}
|
||||
}
|
||||
return support;
|
||||
};
|
||||
|
||||
/** A new scoped registry, or `undefined` if the browser has no support. */
|
||||
export const createScopedRegistry = (): CustomElementRegistry | undefined =>
|
||||
scopedRegistrySupport() === "none" ? undefined : new CustomElementRegistry();
|
||||
|
||||
/** Shadow root options that make the shadow root use `registry`. */
|
||||
export const scopedShadowRootInit = (
|
||||
init: ShadowRootInit,
|
||||
registry: CustomElementRegistry
|
||||
): ShadowRootInit =>
|
||||
scopedRegistrySupport() === "polyfill"
|
||||
? ({ ...init, registry } as ShadowRootInit)
|
||||
: ({ ...init, customElementRegistry: registry } as ShadowRootInit);
|
||||
|
||||
interface CreationScope {
|
||||
importNode(node: Node, deep?: boolean): Node;
|
||||
}
|
||||
|
||||
/**
|
||||
* The object Lit should create template content with, so custom elements in
|
||||
* it are created from the scoped registry (Lit's `creationScope`).
|
||||
*/
|
||||
export const scopedCreationScope = (
|
||||
root: ShadowRoot,
|
||||
registry: CustomElementRegistry
|
||||
): CreationScope => {
|
||||
if (scopedRegistrySupport() === "polyfill") {
|
||||
// The polyfill adds importNode to shadow roots
|
||||
return root as unknown as CreationScope;
|
||||
}
|
||||
// Standard API: the registry is passed to importNode
|
||||
return {
|
||||
importNode: (node, deep) =>
|
||||
(
|
||||
document.importNode as (
|
||||
node: Node,
|
||||
options: { selfOnly: boolean; customElementRegistry: unknown }
|
||||
) => Node
|
||||
)(node, { selfOnly: !deep, customElementRegistry: registry }),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { define } from "../src/utils/registration/define";
|
||||
|
||||
// Home Assistant and custom cards can each ship a copy of the library, so a
|
||||
// second registration of the same tag must be ignored instead of throwing.
|
||||
describe("define", () => {
|
||||
it("registers an element that is not registered yet", () => {
|
||||
class First extends HTMLElement {}
|
||||
define("hac-test-first", First);
|
||||
expect(customElements.get("hac-test-first")).toBe(First);
|
||||
});
|
||||
|
||||
it("keeps the first registration of a tag name", () => {
|
||||
class Original extends HTMLElement {}
|
||||
class Duplicate extends HTMLElement {}
|
||||
define("hac-test-duplicate", Original);
|
||||
expect(() => define("hac-test-duplicate", Duplicate)).not.toThrow();
|
||||
expect(customElements.get("hac-test-duplicate")).toBe(Original);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasAssignedContent,
|
||||
hasAssignedElements,
|
||||
} from "../src/utils/element/slot";
|
||||
|
||||
// Components hide wrappers of empty slots, e.g. the padding of an empty card.
|
||||
// Whitespace between tags must not count as content.
|
||||
const slotEvent = (light: string, name?: string) => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = light;
|
||||
const slot = document.createElement("slot");
|
||||
if (name) {
|
||||
slot.name = name;
|
||||
}
|
||||
host.attachShadow({ mode: "open" }).append(slot);
|
||||
return { target: slot } as unknown as Event;
|
||||
};
|
||||
|
||||
describe("hasAssignedContent", () => {
|
||||
it("ignores whitespace", () => {
|
||||
expect(hasAssignedContent(slotEvent("\n \n"))).toBe(false);
|
||||
});
|
||||
|
||||
it("counts text", () => {
|
||||
expect(hasAssignedContent(slotEvent(" Hello "))).toBe(true);
|
||||
});
|
||||
|
||||
it("counts elements", () => {
|
||||
expect(hasAssignedContent(slotEvent("\n <p></p>\n"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasAssignedElements", () => {
|
||||
it("only counts elements assigned to the slot", () => {
|
||||
const light = '<span slot="icon"></span> text';
|
||||
expect(hasAssignedElements(slotEvent(light, "icon"))).toBe(true);
|
||||
expect(hasAssignedElements(slotEvent(light, "actions"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
diff --git a/modular/sortable.core.esm.js b/modular/sortable.core.esm.js
|
||||
index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa526090a00 100644
|
||||
index cb433ff25353f4836d2ffc813356f945f42acc6b..0a86ad22d05390a5b9ef002905fcd5926852b956 100644
|
||||
--- a/modular/sortable.core.esm.js
|
||||
+++ b/modular/sortable.core.esm.js
|
||||
@@ -1781,11 +1781,16 @@ Sortable.prototype = /** @lends Sortable.prototype */{
|
||||
@@ -1777,11 +1777,16 @@ Sortable.prototype = /** @lends Sortable.prototype */{
|
||||
}
|
||||
if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, !!target) !== false) {
|
||||
capture();
|
||||
@@ -24,7 +24,7 @@ index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa5
|
||||
}
|
||||
parentEl = el; // actualization
|
||||
|
||||
@@ -1802,7 +1807,12 @@ Sortable.prototype = /** @lends Sortable.prototype */{
|
||||
@@ -1798,7 +1803,12 @@ Sortable.prototype = /** @lends Sortable.prototype */{
|
||||
targetRect = getRect(target);
|
||||
if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, false) !== false) {
|
||||
capture();
|
||||
@@ -38,7 +38,16 @@ index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa5
|
||||
parentEl = el; // actualization
|
||||
|
||||
changed();
|
||||
@@ -1849,10 +1859,15 @@ Sortable.prototype = /** @lends Sortable.prototype */{
|
||||
@@ -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 */{
|
||||
_silent = true;
|
||||
setTimeout(_unsilent, 30);
|
||||
capture();
|
||||
@@ -58,3 +67,53 @@ index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa5
|
||||
}
|
||||
|
||||
// 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,
|
||||
Generated
+1572
-1015
File diff suppressed because it is too large.
Load diff
+6
-1
@@ -1,3 +1,7 @@
|
||||
packages:
|
||||
- "."
|
||||
- "packages/*"
|
||||
|
||||
savePrefix: ""
|
||||
autoInstallPeers: false
|
||||
|
||||
@@ -19,6 +23,7 @@ trustPolicyExclude:
|
||||
allowBuilds:
|
||||
"@tsparticles/engine": false
|
||||
core-js: false
|
||||
rs-module-lexer: false
|
||||
unrs-resolver: false
|
||||
|
||||
overrides:
|
||||
@@ -26,7 +31,7 @@ overrides:
|
||||
lit-html: 3.3.3
|
||||
clean-css: 5.3.3
|
||||
"@lit/reactive-element": 2.1.2
|
||||
tslib: 2.8.1
|
||||
tslib: 2.9.1
|
||||
|
||||
patchedDependencies:
|
||||
"@material/[email protected]": patches/@[email protected]
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="94" height="28" rx="8" fill="white"/>
|
||||
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
|
||||
<rect x="8" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="28" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="48" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="68" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 582 B |
@@ -0,0 +1,8 @@
|
||||
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="94" height="28" rx="8" fill="#1C1C1C"/>
|
||||
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="white" stroke-opacity="0.24"/>
|
||||
<rect x="8" y="8" width="18" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="28" y="8" width="18" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="48" y="8" width="18" height="12" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="68" y="8" width="18" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 584 B |
+18
-1
@@ -13,6 +13,9 @@
|
||||
"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,
|
||||
@@ -59,12 +62,26 @@
|
||||
"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)"
|
||||
"mcr\\.microsoft\\.com/playwright:(?<currentValue>v\\d+\\.\\d+\\.\\d+-noble)(?:@(?<currentDigest>sha256:[a-f0-9]{64}))?"
|
||||
],
|
||||
"depNameTemplate": "mcr.microsoft.com/playwright",
|
||||
"datasourceTemplate": "docker",
|
||||
|
||||
@@ -3,6 +3,12 @@ import { ref } from "lit/directives/ref";
|
||||
|
||||
const MAX_ROWS = 2;
|
||||
|
||||
// A narrow list, as on a phone or a half width card, fits about one item per
|
||||
// row, so two rows hide most of the legend. 450px is about the widest list a
|
||||
// phone viewport gives.
|
||||
const NARROW_MAX_ROWS = 3;
|
||||
const NARROW_WIDTH = 450;
|
||||
|
||||
// How many items fit in maxRows of a wrapping row, leaving room for the
|
||||
// "more" item after them when not all of them fit.
|
||||
export const fitInRows = (
|
||||
@@ -34,9 +40,10 @@ export const fitInRows = (
|
||||
return widths.length;
|
||||
};
|
||||
|
||||
// Fits the items of a wrapping legend list into two rows. The list keeps its
|
||||
// "more" item rendered with the legend-more class, and items past the fit
|
||||
// stay rendered out of the flow, so both can always be measured.
|
||||
// Fits the items of a wrapping legend list into two rows, three when it is
|
||||
// narrow. The list keeps its "more" item rendered with the legend-more class,
|
||||
// and items past the fit stay rendered out of the flow, so both can always be
|
||||
// measured.
|
||||
export class LegendFitController {
|
||||
// Items to show while collapsed, undefined until the list is measured
|
||||
public fit?: number;
|
||||
@@ -118,7 +125,7 @@ export class LegendFitController {
|
||||
more?.getBoundingClientRect().width ?? 0,
|
||||
rowWidth,
|
||||
parseFloat(getComputedStyle(list).columnGap) || 0,
|
||||
MAX_ROWS
|
||||
rowWidth < NARROW_WIDTH ? NARROW_MAX_ROWS : MAX_ROWS
|
||||
);
|
||||
if (fit !== this.fit) {
|
||||
this.fit = fit;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// The attributes that hold the address of a link or an image
|
||||
const URL_ATTRIBUTES = [
|
||||
export const URL_ATTRIBUTES = [
|
||||
["a", "href"],
|
||||
["img", "src"],
|
||||
["source", "src"],
|
||||
|
||||
@@ -20,6 +20,11 @@ export interface EntityFilter {
|
||||
|
||||
export type EntityFilterFunc = (entityId: string) => boolean;
|
||||
|
||||
export type EntityFilterSources = Pick<
|
||||
HomeAssistant,
|
||||
"states" | "entities" | "devices" | "areas" | "floors"
|
||||
>;
|
||||
|
||||
const normalizeFilterArray = <T>(
|
||||
value: T | null | T[] | (T | null)[] | undefined
|
||||
): Set<T | null> | undefined => {
|
||||
@@ -157,3 +162,20 @@ export const findEntities = (
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export const findFilteredEntities = (
|
||||
sources: EntityFilterSources,
|
||||
filters: EntityFilter[]
|
||||
): string[] => {
|
||||
const filterFuncs = filters.map((filter) =>
|
||||
generateEntityFilter(
|
||||
sources.states,
|
||||
sources.entities,
|
||||
sources.devices,
|
||||
sources.areas,
|
||||
sources.floors,
|
||||
filter
|
||||
)
|
||||
);
|
||||
return findEntities(Object.keys(sources.states), filterFuncs);
|
||||
};
|
||||
Loaded 100 of 416 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user