Compare commits

..
Author SHA1 Message Date
Petar Petrov 5df1f4344f Refetch entity selector data when the connection comes back
Reset entity sources and config entries when the websocket reconnects, and drop an unreachable cache guard.
2026-10-28 13:26:17 +02:00
Petar Petrov 65a811df8b Refetch entity selector data on reconnect
Fetch config entries once per connection again and keep overlapping cache callers from overwriting newer results.
2026-10-28 13:19:14 +02:00
Petar Petrov 00fd78d3bb Drop hass from the entity selector
fetchEntitySourcesWithCache takes callWS and states and caches per connection in module scope.
2026-10-28 11:45:49 +02:00
791 changed files with 10086 additions and 32687 deletions

No files matched your search

+1 -31
View File
@@ -1,6 +1,6 @@
---
name: ha-frontend-components
description: Home Assistant frontend component patterns. Use when implementing or reviewing dialogs, ha-form, ha-alert, loading states, keyboard shortcuts, tooltips, panels, Lovelace cards, or ha-button usage.
description: Home Assistant frontend component patterns. Use when implementing or reviewing dialogs, ha-form, ha-alert, keyboard shortcuts, tooltips, panels, Lovelace cards, or ha-button usage.
---
# HA Frontend Components
@@ -91,36 +91,6 @@ html`
`;
```
## Loading States
Pick the indicator by what is loading, following the [Loading pattern](https://contributors.home-assistant.io/design/patterns/loading/):
- Use a skeleton from `src/components/skeleton/` (`ha-skeleton`, `ha-skeleton-text`, `ha-skeleton-icon`) when content loads into a layout you already know, such as table rows, tile text, or a log. It holds the layout, so nothing jumps when the content arrives.
- Use `ha-spinner` only when a whole area loads and its shape is unknown.
- Use `ha-button` with `loading`, or `ha-progress-button`, for an action started from a button. Don't put a separate spinner next to it.
Wrap the indicator in `ha-fade-in` from `src/components/animation/ha-fade-in.ts`:
- Fade a skeleton in straight away over about 300ms (`.duration=${300}`), as the data table does. It stands in for the content, so a delay would leave an empty gap.
- Delay a spinner or progress bar, usually by `.delay=${500}`, so a load that finishes within that time never flashes it. Some slower areas, such as backups and updates, use `${1000}`.
- Don't fade in the loaded content itself.
Give a skeleton group the `progressbar` role and a translated label, as `ha-spinner` has, rather than visible "Loading" text. A loading state always ends in the content, an empty state, or an error.
```ts
html`
<ha-fade-in .duration=${300} easing="ease-in">
<div
role="progressbar"
aria-label=${this._i18n.localize("ui.common.loading")}
>
<ha-skeleton-text></ha-skeleton-text>
</div>
</ha-fade-in>
<ha-fade-in .delay=${500}><ha-spinner></ha-spinner></ha-fade-in>
`;
```
## Shortcuts And Tooltips
Use `ShortcutManager` from `src/common/keyboard/shortcuts.ts` for keyboard shortcuts. It blocks shortcuts in input fields, can prevent shortcuts during text selection, and supports character and KeyCode shortcuts for non-latin keyboards. See `src/state/quick-bar-mixin.ts` for global shortcut examples.
@@ -10,7 +10,6 @@ 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.
-4
View File
@@ -12,7 +12,3 @@
demo/public/api/camera_proxy_stream/* binary
demo/public/api/media_player_proxy/* binary
# Generated by `gh actions-lock`. Highlight it as YAML and don't auto-merge it:
# on a conflict, restore it from the base branch and run `gh actions-lock`.
.github/workflows/actions.lock linguist-language=YAML -merge
+3 -5
View File
@@ -51,8 +51,8 @@
- This PR fixes or closes issue: fixes #
- This PR is related to issue or discussion:
- Link to [documentation][docs-repository] pull request:
- Link to [contributors documentation][contributors-repository] pull request:
- Link to documentation pull request:
- Link to developer documentation pull request:
- Link to backend pull request:
## Checklist
@@ -63,8 +63,7 @@
for before merging your code.
AI tools are welcome, but contributors are responsible for *fully*
understanding the code before submitting a PR. Please follow our AI policy:
https://contributors.home-assistant.io/start/ai-policy/
understanding the code before submitting a PR.
-->
- [ ] I understand the code I am submitting and can explain how it works.
@@ -106,5 +105,4 @@ To help with the load of incoming pull requests:
Below, some useful links you could explore:
-->
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[contributors-repository]: https://github.com/home-assistant/contributors.home-assistant.io
[perfect-pr]: https://contributors.home-assistant.io/start/pull-requests/#before-you-open-it
-6
View File
@@ -1,6 +0,0 @@
paths:
.github/workflows/**/*.{yml,yaml}:
ignore:
# actionlint doesn't support `$/` self-repository actions yet, remove once it does:
# https://github.com/rhysd/actionlint/issues/711
- 'specifying action "\$/[^"]+" in invalid format because ref is missing'
+3 -4
View File
@@ -35,12 +35,11 @@ runs:
NETLIFY_AUTH_TOKEN: ${{ inputs.auth-token }}
NETLIFY_SITE_ID: ${{ inputs.site-id }}
run: |
# Execute the deployment (--filter picks the root workspace project,
# --no-build because we deploy an already built directory)
# Execute the deployment
if [ -n "$ALIAS" ]; then
npx -y netlify-cli deploy --filter home-assistant-frontend --no-build --dir="$DIR" --alias "$ALIAS" --json > deploy_output.json
npx -y netlify-cli deploy --dir="$DIR" --alias "$ALIAS" --json > deploy_output.json
else
npx -y netlify-cli deploy --filter home-assistant-frontend --no-build --dir="$DIR" --prod --json > deploy_output.json
npx -y netlify-cli deploy --dir="$DIR" --prod --json > deploy_output.json
fi
# Collect the urls from the deployment output
@@ -16,7 +16,6 @@ runs:
with:
path: |
node_modules
packages/*/node_modules
~/.local/share/pnpm/store
key: >-
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
@@ -25,7 +24,7 @@ runs:
- name: Setup Node and install
if: steps.dependencies.outputs.cache-hit != 'true'
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
cache: false
@@ -36,7 +35,6 @@ runs:
with:
path: |
node_modules
packages/*/node_modules
~/.local/share/pnpm/store
key: >-
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
-1
View File
@@ -28,7 +28,6 @@ runs:
with:
path: |
node_modules
packages/*/node_modules
~/.local/share/pnpm/store
key: >-
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
-41
View File
@@ -1,41 +0,0 @@
name: Verify actions lockfile
on:
push:
branches:
- dev
- master
paths:
- ".github/actions/**"
- ".github/workflows/**"
pull_request:
branches:
- dev
- master
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
name: Verify actions lockfile
runs-on: ubuntu-latest
env:
GH_ACTIONS_LOCK_VERSION: v0.4.1
steps:
- name: Check out files from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install gh-actions-lock
env:
GH_TOKEN: ${{ github.token }}
run: gh extension install github/gh-actions-lock --pin "${GH_ACTIONS_LOCK_VERSION}"
- name: Verify lockfile
env:
GH_TOKEN: ${{ github.token }}
run: gh actions-lock --verify
-190
View File
@@ -1,190 +0,0 @@
# This file is machine-generated by `gh actions-lock`.
# Do not edit by hand; run `gh actions-lock` to update.
# Docs: https://gh.io/actions-lockfile
version: 'v0.0.3'
workflows:
'.github/workflows/actionlint.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
'.github/workflows/actions-lock.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
'.github/workflows/cast_deployment.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/ci.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
'.github/workflows/codeql-analysis.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'github/codeql-action@9b3254085494b55a0f7c4c2b3d2d52cc712ce370'
'.github/workflows/copilot-setup-steps.yml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/demo_deployment.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/dependency-review.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
'.github/workflows/design_deployment.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/design_preview.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
'.github/workflows/e2e.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
'.github/workflows/labeler.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
- 'actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13'
'.github/workflows/lock.yaml':
- 'dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c'
'.github/workflows/nightly.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
'.github/workflows/pull-request-standards.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
'.github/workflows/relative-ci.yaml':
- 'relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60'
'.github/workflows/release-drafter.yaml':
- 'release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0'
'.github/workflows/release.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
- 'home-assistant/actions@86d4564712fa02543c808e3a693dd04f9d3f8361'
- 'home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716'
- 'pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086'
'.github/workflows/restrict-task-creation.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
'.github/workflows/stale.yaml':
- 'actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93'
'.github/workflows/sync-device-classes.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1'
'.github/workflows/sync-sensor-entity-constants.yaml':
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
- 'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1'
'.github/workflows/translations.yaml':
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
dependencies:
'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9':
ref: 'v6.1.0'
commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
owner_id: 44036562
repo_id: 215566462
'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1':
ref: 'v7.0.1'
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
owner_id: 44036562
repo_id: 197814629
'actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294':
ref: 'v5.0.0'
commit: 'sha1-a1d282b36b6f3519aa1f3fc636f609c47dddb294'
owner_id: 44036562
repo_id: 476372928
'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333':
ref: 'v8.0.2'
commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333'
owner_id: 44036562
repo_id: 192626254
'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3':
ref: 'v9.0.0'
commit: 'sha1-3a2844b7e9c422d3c10d287c895573f7108da1b3'
owner_id: 44036562
repo_id: 205262760
'actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13':
ref: 'v7.0.0'
commit: 'sha1-bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13'
owner_id: 44036562
repo_id: 177139928
'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1':
ref: 'v7.1.0'
commit: 'sha1-949feb2413d6458794dcd2491c4babbbce0c15c1'
owner_id: 44036562
repo_id: 189476904
'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0':
ref: 'v7.1.0'
commit: 'sha1-6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
owner_id: 44036562
repo_id: 192625525
'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405':
ref: 'v6.2.0'
commit: 'sha1-a309ff8b426b58ec0e2a45f0f869d46889d02405'
owner_id: 44036562
repo_id: 192625525
'actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93':
ref: 'v11.0.0'
commit: 'sha1-4391f3da665fdf50b6810c1a66712fb9ba21aa93'
owner_id: 44036562
repo_id: 200437066
'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9':
ref: 'v7.0.2'
commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9'
owner_id: 44036562
repo_id: 192625955
'dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c':
ref: 'v6.0.2'
commit: 'sha1-89ae32b08ed1a541efecbab17912962a5e38981c'
owner_id: 7513068
repo_id: 113918130
'github/codeql-action@9b3254085494b55a0f7c4c2b3d2d52cc712ce370':
ref: 'v4.39.1'
commit: 'sha1-9b3254085494b55a0f7c4c2b3d2d52cc712ce370'
owner_id: 9919
repo_id: 259445878
'home-assistant/actions@86d4564712fa02543c808e3a693dd04f9d3f8361':
ref: 'master'
commit: 'sha1-86d4564712fa02543c808e3a693dd04f9d3f8361'
owner_id: 13844975
repo_id: 173703505
'home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716':
ref: '2026.07.0'
commit: 'sha1-9e17ab1ed5c4c79d8b61e29fa63de25ca2710716'
owner_id: 13844975
repo_id: 184234812
'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1':
ref: 'v8.1.1'
commit: 'sha1-5f6978faf089d4d20b00c7766989d076bb2fc7f1'
owner_id: 18365890
repo_id: 197176365
'pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086':
ref: 'v1.14.3'
commit: 'sha1-7245906fb61fa33a13554f9d5fb856fe61758086'
owner_id: 647025
repo_id: 178055147
uses:
- 'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405'
'relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60':
ref: 'v3.2.4'
commit: 'sha1-711f3a8fa1b928e6afde280011110c9b0a224a60'
owner_id: 43522883
repo_id: 398376354
'release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0':
ref: 'v7.10.0'
commit: 'sha1-978bb0b30c5ee8cdd332ced253758157150d43f0'
owner_id: 58292376
repo_id: 139022359
+8 -8
View File
@@ -18,7 +18,7 @@ jobs:
deploy_dev:
runs-on: ubuntu-latest
name: Deploy Development
if: github.repository == 'home-assistant/frontend' && github.ref_name != 'master'
if: github.repository == 'home-assistant/frontend' && github.event_name != 'push'
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.ref_name == 'master'
if: github.repository == 'home-assistant/frontend' && github.event_name == 'push'
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 }}
+5 -5
View File
@@ -31,7 +31,7 @@ jobs:
with:
persist-credentials: false
- name: Prepare dependencies
uses: $/.github/actions/prepare-dependencies
uses: ./.github/actions/prepare-dependencies
lint:
name: Lint and check format
@@ -43,7 +43,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Check for duplicate dependencies
@@ -89,7 +89,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build resources
@@ -110,7 +110,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
# Read-only reuse of the rspack cache written by the nightly (see
@@ -127,7 +127,7 @@ jobs:
restore-keys: |
rspack-cache-${{ runner.os }}-
- name: Build Application
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-app
github-token: ${{ secrets.GITHUB_TOKEN }}
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
category: "/language:javascript-typescript"
+3 -38
View File
@@ -22,45 +22,10 @@ jobs:
with:
persist-credentials: false
# These steps mirror .github/actions/setup with node-modules-cache enabled.
# Copilot runs this workflow in a context that can't resolve `$/`
# self-repository actions, so the steps are inlined here.
- name: Restore complete dependency tree
id: dependency-cache
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- name: Setup Node and install
uses: ./.github/actions/setup
with:
path: |
node_modules
~/.local/share/pnpm/store
key: >-
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
hashFiles('.nvmrc', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'patches/**') }}
- name: Setup Node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: ".nvmrc"
- name: Enable Corepack
run: corepack enable
- name: Get pnpm store directory
id: pnpm-store
if: steps.dependency-cache.outputs.cache-hit != 'true'
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Cache pnpm store
if: steps.pnpm-store.outputs.path
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-store-${{ runner.os }}-${{ runner.arch }}-
- name: Install dependencies
if: steps.dependency-cache.outputs.cache-hit != 'true'
run: pnpm install --frozen-lockfile
node-modules-cache: true
- name: Skip fetching nightly translations
run: echo "SKIP_FETCH_NIGHTLY_TRANSLATIONS=1" >> "$GITHUB_ENV"
+8 -8
View File
@@ -19,7 +19,7 @@ jobs:
deploy_dev:
runs-on: ubuntu-latest
name: Demo Development
if: github.repository == 'home-assistant/frontend' && github.ref_name != 'master'
if: github.repository == 'home-assistant/frontend' && (github.event_name != 'push' || 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.ref_name == 'master'
if: github.repository == 'home-assistant/frontend' && github.event_name == 'push' && 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 }}
+3 -3
View File
@@ -25,17 +25,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Build Gallery
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
uses: $/.github/actions/netlify-deploy
uses: ./.github/actions/netlify-deploy
with:
dir: gallery/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+3 -3
View File
@@ -29,17 +29,17 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Build Gallery
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy preview to Netlify
id: deploy
uses: $/.github/actions/netlify-deploy
uses: ./.github/actions/netlify-deploy
with:
dir: gallery/dist
alias: deploy-preview-${{ github.event.number }}
+16 -16
View File
@@ -32,13 +32,13 @@ jobs:
persist-credentials: false
- name: Prepare dependencies
uses: $/.github/actions/prepare-dependencies
uses: ./.github/actions/prepare-dependencies
prepare-container-dependencies:
name: Prepare container dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.65.0-noble@sha256:6e3fb709361521eb27161606e6da6d2674e5e89fa16516cb37d6fdd91372273d
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001
defaults:
run:
@@ -50,7 +50,7 @@ jobs:
persist-credentials: false
- name: Prepare dependencies
uses: $/.github/actions/prepare-dependencies
uses: ./.github/actions/prepare-dependencies
with:
node-modules-cache-key: node-modules-container-v1
@@ -66,12 +66,12 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build demo
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-demo-e2e
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -97,12 +97,12 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build e2e test app
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-e2e-test-app-e2e
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -128,12 +128,12 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Build gallery
uses: $/.github/actions/build
uses: ./.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -155,7 +155,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.65.0-noble@sha256:6e3fb709361521eb27161606e6da6d2674e5e89fa16516cb37d6fdd91372273d
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -176,7 +176,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
node-modules-cache-key: node-modules-container-v1
@@ -207,7 +207,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.65.0-noble@sha256:6e3fb709361521eb27161606e6da6d2674e5e89fa16516cb37d6fdd91372273d
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -230,7 +230,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
node-modules-cache-key: node-modules-container-v1
@@ -261,7 +261,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.65.0-noble@sha256:6e3fb709361521eb27161606e6da6d2674e5e89fa16516cb37d6fdd91372273d
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -284,7 +284,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
node-modules-cache-key: node-modules-container-v1
@@ -326,7 +326,7 @@ jobs:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
node-modules-cache: true
+3 -3
View File
@@ -26,12 +26,12 @@ jobs:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
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
+5 -5
View File
@@ -31,15 +31,15 @@ jobs:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@86d4564712fa02543c808e3a693dd04f9d3f8361 # master
uses: home-assistant/actions/helpers/verify-version@cd6f3635a740c84d8a14f6121a90068b8ece6bf6 # master
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
immutable: false
cache: false
@@ -85,7 +85,7 @@ jobs:
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
skip-existing: true
@@ -146,7 +146,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
with:
immutable: false
cache: false
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Regenerate device class constants
run: ./script/gen_device_classes
@@ -28,7 +28,7 @@ jobs:
persist-credentials: false
- name: Setup Node and install
uses: $/.github/actions/setup
uses: ./.github/actions/setup
- name: Regenerate sensor entity constants
run: ./script/gen_sensor_entity_constants
+1 -1
View File
@@ -1 +1 @@
24.22.2
24.22.1
+1 -1
View File
@@ -40,7 +40,7 @@ Never run `tsc` or `pnpm lint:types` with file arguments. When `tsc` receives fi
Detailed guidance lives in `.agents/skills/<name>/SKILL.md`. Load every matching skill before detailed implementation or review. For reviews, load `ha-frontend-review` alongside all companions that apply to the changed code or behavior:
- `ha-frontend-contexts`: Lit contexts, `hass` migration, and rerender-sensitive state access.
- `ha-frontend-components`: dialogs, forms, alerts, loading states, shortcuts, tooltips, panels, and Lovelace cards.
- `ha-frontend-components`: dialogs, forms, alerts, shortcuts, tooltips, panels, and Lovelace cards.
- `ha-frontend-events`: event handler typing, custom event dispatch, and event-map declarations.
- `ha-frontend-types`: backend data contracts, optional schemas, shared types, assertions, and lifecycle types.
- `ha-frontend-lit`: reactive fields, DOM queries, lifecycle behavior, and render-derived state.
-8
View File
@@ -28,18 +28,10 @@ AI can be a helpful tool for writing code and documentation. However, due to the
All contributions must be reviewed and understood by the contributor before submission. You should be able to explain every change in a pull request you submit. Pull requests that appear to be unreviewed AI output will be closed without review.
## Design contributions
AI can be a helpful tool to explore ideas and build mockups and prototypes. But AI tends to give the most common answer, not the one that fits Home Assistant and its users. So we require a human who brings the point of view and owns the design decisions.
You should be able to explain the problem your design solves and why you made your choices. If a mockup or prototype was mostly generated with AI, say so when you share it. Check your work against the [design guidelines](https://contributors.home-assistant.io/design/), as AI tools often make up components, colors, or spacing that don't exist in Home Assistant. Design proposals that look like unreviewed AI output may be closed or hidden.
## Our use of AI
Some of our projects use AI tools to assist with code reviews, issue triaging, reporting, and other project management tasks. These tools may leave comments on pull requests or issues. As with any automated tooling, these comments are not always correct.
We also use AI to help keep the design system, the Figma kit, and the code in sync. The design decisions behind them are made by people.
If an AI tool leaves a comment on your contribution, treat it as you would any other review comment. If you believe it is incorrect, say so; a brief explanation is sufficient. Maintainers always have the final say. If in doubt, ask a maintainer.
## Enforcement
+2 -2
View File
@@ -2,13 +2,13 @@
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
"frontend-modern": {
"app": 627654,
"core": 57901,
"core": 55147,
"authorize": 593862,
"onboarding": 709255
},
"frontend-legacy": {
"app": 724225,
"core": 163115,
"core": 157026,
"authorize": 693913,
"onboarding": 812415
}
+8 -24
View File
@@ -117,16 +117,6 @@ module.exports.swcOptions = () => ({
},
});
const corejsPolyfills = (exclude = []) => [
"babel-plugin-polyfill-corejs3",
{
method: "usage-global",
version: dependencies["core-js"],
shippedProposals: true,
exclude,
},
];
module.exports.babelOptions = ({ latestBuild, isTestBuild, sw, worker }) => ({
babelrc: false,
compact: false,
@@ -148,7 +138,14 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw, worker }) => ({
// Inject Core-JS polyfills on demand. Babel 8 removed preset-env's
// `useBuiltIns`/`corejs` options, so the equivalent polyfill provider is
// configured directly here (`usage-global` matches the old `useBuiltIns: "usage"`).
corejsPolyfills(),
[
"babel-plugin-polyfill-corejs3",
{
method: "usage-global",
version: dependencies["core-js"],
shippedProposals: true,
},
],
[
path.join(BABEL_PLUGINS, "inline-constants-plugin.cjs"),
{
@@ -194,16 +191,6 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw, worker }) => ({
].map((p) => new RegExp(`/node_modules/${p}/`)),
],
},
{
// It only constructs `Uint8Array`s, but any reference to one pulls in
// every typed array polyfill, and this lands in the `core` entrypoint.
test: /[\\/]node_modules[\\/]home-assistant-js-websocket[\\/]/,
plugins: [
corejsPolyfills([
/^(es|esnext)\.(array-buffer|typed-array|uint8-array)\./,
]),
],
},
{
// Use unambiguous for dependencies so that require() is correctly injected into CommonJS files
// Exclusions are needed in some cases where ES modules have no static imports or exports, such as polyfills
@@ -284,7 +271,6 @@ module.exports.config = {
name: "demo" + nameSuffix(latestBuild),
entry: {
main: path.resolve(paths.demo_dir, "src/entrypoint.ts"),
embed: path.resolve(paths.demo_dir, "src/embed/entrypoint.ts"),
...module.exports.mapWorkerEntry(),
},
outputPath: outputPath(paths.demo_output_root, latestBuild),
@@ -292,8 +278,6 @@ module.exports.config = {
defineOverlay: {
__VERSION__: JSON.stringify(`DEMO-${env.version()}`),
__DEMO__: true,
// The embed sets the global to load static files from its own server.
__STATIC_PATH__: '(globalThis.__HA_STATIC_PATH__ || "/static/")',
},
isProdBuild,
latestBuild,
+1 -5
View File
@@ -232,11 +232,7 @@ gulp.task(
)
);
const DEMO_PAGE_ENTRIES = {
"index.html": ["main"],
"components.html": ["embed"],
"embed.js": ["embed"],
};
const DEMO_PAGE_ENTRIES = { "index.html": ["main"] };
gulp.task(
"gen-pages-demo-dev",
@@ -1,3 +1,4 @@
/* 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.
@@ -35,14 +36,6 @@ 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,
};
})
);
@@ -74,13 +67,12 @@ module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
const { browserslistEnv } = this.getOptions();
initLoader(browserslistEnv)
.then(({ minifyHTMLLiterals, lightningcssTargets, lightningcssInclude }) =>
.then(({ minifyHTMLLiterals, lightningcssTargets }) =>
minifyHTMLLiterals(source, {
fileName: this.resourcePath,
html: htmlOptions,
css: {
targets: lightningcssTargets,
include: lightningcssInclude,
},
})
)
+8 -56
View File
@@ -68,42 +68,8 @@ 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");
@@ -248,18 +214,16 @@ const createRspackConfig = ({
},
},
{
// MapLibre's worker loads ESM plugins through `import(url)`, which
// is never taken here: the RTL plugin is UMD. Rspack's stub for that
// fully dynamic request is exactly what should remain - rejecting,
// and free of syntax the legacy floor cannot parse - so the warning
// about it is noise. The page side builds a blob import from
// `new URL(url, import.meta.url)` for a worker on another origin,
// as in the demo embed. That is a URL, not an asset to bundle, and
// the worker URL is absolute, so leave it as it is.
// MapLibre's worker loads ESM plugins through `import(url)`, and the
// page side builds a blob import from `new URL(url, import.meta.url)`
// for a cross-origin worker. Neither path is taken here: the RTL
// plugin is UMD and the worker is served from the page's origin.
// Rspack's stubs for these fully dynamic requests are exactly what
// should remain - rejecting, and free of syntax the legacy floor
// cannot parse - so the warnings about them are noise.
test: /[\\/]maplibre-gl[\\/]dist[\\/]maplibre-gl(?:-worker)?\.mjs$/,
parser: {
exprContextCritical: false,
url: false,
},
},
{
@@ -278,22 +242,11 @@ 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: [
@@ -465,7 +418,6 @@ 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",
+5 -5
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"background_color": "#FFFFFF",
"description": "Show Home Assistant on your Chromecast or Google Home devices with a screen.",
"description": "Show Home Assistant on your Chromecast or Google Assistant devices with a screen.",
"dir": "ltr",
"display": "standalone",
"icons": [
+2 -2
View File
@@ -5,7 +5,7 @@
<meta property="og:type" content="website" />
<meta
property="og:description"
content="Show Home Assistant on your Chromecast or Google Home devices with a screen."
content="Show Home Assistant on your Chromecast or Google Assistant 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 Home devices with a screen."
content="Show Home Assistant on your Chromecast or Google Assistant devices with a screen."
/>
<meta
name="twitter:image"
+1 -2
View File
@@ -3,7 +3,6 @@
<head>
<title>Home Assistant Cast - FAQ</title>
<link rel="icon" href="/images/ha-cast-icon.png" type="image/png" />
<%= renderTemplate("../../../src/html/_meta_viewport.html.template") %>
<%= renderTemplate("../../../src/html/_style_base.html.template") %>
<style>
body {
@@ -53,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 Home devices with a screen.
Google Assistant devices with a screen.
</p>
</div>
-1
View File
@@ -4,7 +4,6 @@
<title>Home Assistant Cast</title>
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/images/ha-cast-icon.png" type="image/png" />
<%= renderTemplate("../../../src/html/_meta_viewport.html.template") %>
<%= renderTemplate("../../../src/html/_style_base.html.template") %>
<style>
body {
-1
View File
@@ -4,7 +4,6 @@
<% for (const entry of latestEntryJS) { %>
<script type="module" src="<%= entry %>"></script>
<% } %>
<%= renderTemplate("../../../src/html/_meta_viewport.html.template") %>
<%= renderTemplate("../../../src/html/_style_base.html.template") %>
<style>
body {
+50 -42
View File
@@ -1,7 +1,8 @@
import type { ActionDetail } from "@material/mwc-list/mwc-list";
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, nothing } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { CastManager } from "../../../../src/cast/cast_manager";
import {
@@ -13,14 +14,12 @@ 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,
@@ -54,9 +53,6 @@ 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}>
${
@@ -97,41 +93,45 @@ class HcCast extends LitElement {
</p>
`
: html`
<div class="section-header" id="pick-view-header">
PICK A VIEW
</div>
<ha-list-selectable
controlled
aria-labelledby="pick-view-header"
>
${views.map(
<div class="section-header">PICK A VIEW</div>
<ha-list @action=${this._handlePickView} activatable>
${(
this.lovelaceViews ?? [
{
title: "Home",
},
]
).map(
(view, idx) => html`
<ha-list-item-option
data-index=${idx}
@click=${this._handlePickView}
.headline=${view.title || view.path || "Unnamed view"}
<ha-list-item
graphic="avatar"
.activated=${
this.castManager.status?.lovelacePath ===
(view.path ?? idx)
}
.selected=${
this.castManager.status?.lovelacePath ===
(view.path ?? idx)
}
>
${view.title || view.path || "Unnamed view"}
${
!hasIcons
? nothing
: view.icon
? html`<ha-icon
slot="start"
view.icon
? html`
<ha-icon
.icon=${view.icon}
></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiViewDashboard}
></ha-svg-icon>`
slot="graphic"
></ha-icon>
`
: html`<ha-svg-icon
slot="item-icon"
.path=${mdiViewDashboard}
></ha-svg-icon>`
}
</ha-list-item-option>
</ha-list-item>
`
)}
</ha-list-selectable>
)}</ha-list
>
`
}
@@ -194,6 +194,14 @@ 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;
}
@@ -207,13 +215,8 @@ class HcCast extends LitElement {
this.castManager.requestSession();
}
// 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;
private async _handlePickView(ev: CustomEvent<ActionDetail>) {
const path = this.lovelaceViews?.[ev.detail.index]?.path ?? ev.detail.index;
await ensureConnectedCastSession(this.castManager!, this.auth!);
castSendShowLovelaceView(this.castManager, this.auth.data.hassUrl, path);
}
@@ -268,11 +271,16 @@ class HcCast extends LitElement {
color: var(--secondary-text-color);
}
ha-list-item-option ha-icon,
ha-list-item-option ha-svg-icon {
ha-list-item ha-icon,
ha-list-item ha-svg-icon {
padding: 12px;
color: var(--secondary-text-color);
}
:host([hide-icons]) ha-icon {
display: none;
}
.spacer {
flex: 1;
}
+1 -1
View File
@@ -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 Home devices with a screen.
Chromecast video devices and to Google Assistant devices with a screen.
</p>
<p>
For more information, see the
+1 -1
View File
@@ -6,7 +6,7 @@ const playerManager = castContext.getPlayerManager();
playerManager.setMessageInterceptor("LOAD", (loadRequestData) => {
const media = loadRequestData.media;
// Special handling if it came from Google Home
// Special handling if it came from Google Assistant
if (media.entity) {
media.contentId = media.entity;
media.streamType = "LIVE";
+1 -1
View File
@@ -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 Home 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 Assistant 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."
+1 -1
View File
@@ -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 Home
// Special handling if it came from Google Assistant
if (media.entity) {
media.contentId = media.entity;
media.streamType = "LIVE";
+5 -12
View File
@@ -1,25 +1,18 @@
/*
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
Access-Control-Allow-Origin: *
/embed.js
Access-Control-Allow-Origin: *
/static/*
Access-Control-Allow-Origin: *
Cache-Control: public, max-age: 604800, s-maxage=604800
-4
View File
@@ -1,4 +0,0 @@
import { createContext } from "@lit/context";
import type { HomeAssistant } from "../../../src/types";
export const embedHassContext = createContext<HomeAssistant>("embedHass");
-173
View File
@@ -1,173 +0,0 @@
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>`;
}
}
-28
View File
@@ -1,28 +0,0 @@
import "./public-path";
import "./workers";
// The provider must be defined first. Custom elements upgrade in the order
// they are defined, and cards need a provider to request their context from.
import "./ha-embed-provider";
import "./ha-embed-card";
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");
-99
View File
@@ -1,99 +0,0 @@
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;
}
}
-173
View File
@@ -1,173 +0,0 @@
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;
}
}
-321
View File
@@ -1,321 +0,0 @@
import { ContextProvider, provide } from "@lit/context";
import type { HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../src/common/dom/fire_event";
import { listenMediaQuery } from "../../../src/common/dom/media_query";
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
import type { CalendarEventApiData } from "../../../src/data/calendar";
import { reducedMotionContext } from "../../../src/data/context";
import type { 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 };
}
}
-95
View File
@@ -1,95 +0,0 @@
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;
}
}
-134
View File
@@ -1,134 +0,0 @@
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;
}
}
-217
View File
@@ -1,217 +0,0 @@
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);
}
`,
];
}
-20
View File
@@ -1,20 +0,0 @@
/* eslint-disable @typescript-eslint/naming-convention */
declare global {
var __HA_STATIC_PATH__: string | undefined;
}
declare let __webpack_public_path__: string;
/* eslint-enable @typescript-eslint/naming-convention */
// Load chunks and static files from the server that hosts this script, so
// that other sites can embed the cards. Only the modern production build keeps
// `import.meta.url` (see build-scripts/rspack.cjs). The legacy and development
// builds only run on the demo site, where the default paths are correct.
// `new URL(path, import.meta.url)` in one expression is an asset reference for
// the bundler, so the script URL goes into a variable first.
if (__BUILD__ === "modern" && !__DEV__) {
const scriptUrl = import.meta.url;
__webpack_public_path__ = new URL(".", scriptUrl).href;
globalThis.__HA_STATIC_PATH__ = new URL("../static/", scriptUrl).href;
}
export {};
-31
View File
@@ -1,31 +0,0 @@
import { load } from "js-yaml";
/**
* Parses the data in a `<script type="application/json">` or
* `<script type="text/yaml">` child of the element.
*/
export const readScriptData = (element: HTMLElement): unknown => {
const script = element.querySelector<HTMLScriptElement>(
':scope > script[type="application/json"], :scope > script[type="text/yaml"]'
);
if (!script) {
return undefined;
}
return script.type === "text/yaml"
? load(script.textContent!)
: JSON.parse(script.textContent!);
};
/** 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;
};
-95
View File
@@ -1,95 +0,0 @@
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;
}
`,
];
-32
View File
@@ -1,32 +0,0 @@
// Browsers only start a worker from a script on the page's own origin, and
// the workers of the cards, such as the markdown worker, are served next to
// this script. A worker from another origin starts from a blob instead, which
// loads the real worker script. The demo serves its scripts with CORS.
const pageWorker = globalThis.Worker;
globalThis.Worker = class extends pageWorker {
constructor(scriptUrl: string | URL, options?: WorkerOptions) {
const url = new URL(scriptUrl, location.href);
if (url.origin === location.origin) {
super(scriptUrl, options);
return;
}
const source =
options?.type === "module"
? `import ${JSON.stringify(url.href)};`
: `importScripts(${JSON.stringify(url.href)});`;
const blobUrl = URL.createObjectURL(
new Blob([source], { type: "text/javascript" })
);
try {
super(blobUrl, options);
} finally {
URL.revokeObjectURL(blobUrl);
}
}
};
export {};
-5
View File
@@ -36,7 +36,6 @@ import { mockHardware } from "./stubs/hardware";
import { mockHassioSupervisor } from "./stubs/hassio_supervisor";
import { mockIntegration } from "./stubs/integration";
import { mockLabelRegistry } from "./stubs/label_registry";
import { mockLogbook } from "./stubs/logbook";
import { automationPlatformDomains } from "./stubs/automation_platform_domains";
import { mockIcons } from "./stubs/icons";
import { mockHistory } from "./stubs/history";
@@ -51,7 +50,6 @@ import { mockTemplate } from "./stubs/template";
import { mockTodo } from "./stubs/todo";
import { mockTranslations } from "./stubs/translations";
import { mockUsagePrediction } from "./stubs/usage_prediction";
import { mockWeather } from "./stubs/weather";
// WS command / REST path prefixes whose mocks live in the lazily imported
// config-panel chunk (see ./stubs/config-panel). Must stay in sync with it.
@@ -77,7 +75,6 @@ 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",
@@ -193,9 +190,7 @@ export class HaDemo extends HomeAssistantAppEl {
mockSystemLog(hass);
mockTemplate(hass);
mockEvents(hass);
mockLogbook(hass);
mockMediaPlayer(hass);
mockWeather(hass);
mockFrontend(hass);
mockHardware(hass);
mockHassioSupervisor(hass);
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1 +0,0 @@
import "<%= latestEntryJS[0] %>";
+90 -3
View File
@@ -35,12 +35,13 @@
/>
<meta name="theme-color" content="#03a9f4" />
<%= renderTemplate("_social_meta.html.template") %>
<%= renderTemplate("../../../src/html/_style_base.html.template") %>
<style>
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
height: 100vh;
/* Prevent iOS Safari from inflating text when rotating to landscape */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
@media (prefers-color-scheme: dark) {
html {
@@ -48,8 +49,94 @@
color: var(--primary-text-color, #e1e1e1);
}
}
body {
font-family: Roboto, Noto, sans-serif;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
font-weight: 400;
height: 100vh;
margin: 0;
padding: 0;
}
#ha-launch-screen {
font-family: ui-sans-serif, system-ui, sans-serif;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
user-select: none;
-webkit-user-select: none;
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
}
#ha-launch-screen.removing {
opacity: 0;
}
@keyframes launch-lockup-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
#ha-launch-screen .ha-lockup {
display: flex;
align-items: center;
flex-shrink: 0;
animation: launch-lockup-in var(--ha-animation-duration-normal, 250ms) ease-out both;
}
#ha-launch-screen-info-box > *,
#ha-launch-screen .ohf-logo {
animation: launch-lockup-in var(--ha-animation-duration-normal, 250ms) ease-out both;
}
#ha-launch-screen .ha-logo {
width: 96px;
height: 96px;
}
#ha-launch-screen .ha-launch-screen-spacer-top {
flex: 1;
margin-top: calc( 2 * max(var(--safe-area-inset-top, 0px), 48px) + 46px );
padding-top: 48px;
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
flex: 1;
padding-top: 16px;
}
.ohf-logo {
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
opacity: .66;
}
.ohf-logo span {
font-size: 12px;
line-height: 12px;
text-transform: uppercase;
}
.ohf-logo picture {
display: flex;
}
.ohf-logo img {
width: 237px;
aspect-ratio: 237 / 24;
height: auto;
}
@media (max-height: 560px) {
#ha-launch-screen .ha-launch-screen-spacer-top {
margin-top: 24px;
padding-top: 24px;
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
padding-top: 16px;
}
.ohf-logo {
margin-block: 24px;
}
}
</style>
<%= renderTemplate("../../../src/html/_style_launch_screen.html.template") %>
</head>
<body>
<div id="ha-launch-screen">
+13 -15
View File
@@ -12,20 +12,6 @@ export interface DemoArea {
let areas: AreaRegistryEntry[] = [];
export const toAreaRegistryEntry = (area: DemoArea): AreaRegistryEntry => ({
area_id: area.area_id,
name: area.name,
floor_id: area.floor_id ?? null,
icon: area.icon ?? null,
temperature_entity_id: area.temperature_entity_id ?? null,
humidity_entity_id: area.humidity_entity_id ?? null,
aliases: [],
labels: [],
picture: null,
created_at: 0,
modified_at: 0,
});
export const mockAreaRegistry = (
hass: MockHomeAssistant,
data: DemoArea[] = []
@@ -39,7 +25,19 @@ export const setDemoAreas = (
hass: MockHomeAssistant,
demoAreas: DemoArea[] = []
) => {
areas = demoAreas.map(toAreaRegistryEntry);
areas = demoAreas.map((area) => ({
area_id: area.area_id,
name: area.name,
floor_id: area.floor_id ?? null,
icon: area.icon ?? null,
temperature_entity_id: area.temperature_entity_id ?? null,
humidity_entity_id: area.humidity_entity_id ?? null,
aliases: [],
labels: [],
picture: null,
created_at: 0,
modified_at: 0,
}));
const areasById: Record<string, AreaRegistryEntry> = {};
areas.forEach((area) => {
areasById[area.area_id] = area;
+120 -10
View File
@@ -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,6 +20,109 @@ interface EntityFilter {
supported_features?: number[];
}
// Expands a target to its areas, devices and entities, like core does
const expandTarget = (
hass: MockHomeAssistant,
target: HassServiceTarget,
primaryEntitiesOnly = true
) => {
const missing = {
floors: [] as string[],
areas: [] as string[],
devices: [] as string[],
labels: [] as string[],
};
const knownLabelIds = getLabelIds();
const floorIds = ensureArray(target.floor_id ?? []);
const labelIds = ensureArray(target.label_id ?? []);
missing.floors.push(...floorIds.filter((id) => !(id in hass.floors)));
// Like core, unknown labels are reported, but still expanded
missing.labels.push(...labelIds.filter((id) => !knownLabelIds.includes(id)));
const hasLabel = (labels?: string[]) =>
labelIds.some((labelId) => labels?.includes(labelId));
// Unknown areas and devices are reported, but still referenced
const areaIds = new Set(ensureArray(target.area_id ?? []));
missing.areas.push(...[...areaIds].filter((id) => !(id in hass.areas)));
for (const area of Object.values(hass.areas)) {
if (
(area.floor_id && floorIds.includes(area.floor_id)) ||
hasLabel(area.labels)
) {
areaIds.add(area.area_id);
}
}
// A targeted or labeled device includes its child devices
const deviceIds = new Set<string>();
const addDevice = (deviceId: string) => {
deviceIds.add(deviceId);
for (const device of Object.values(hass.devices)) {
if (device.parent_device_id === deviceId) {
deviceIds.add(device.id);
}
}
};
for (const deviceId of ensureArray(target.device_id ?? [])) {
if (!(deviceId in hass.devices)) {
missing.devices.push(deviceId);
}
addDevice(deviceId);
}
for (const device of Object.values(hass.devices)) {
if (hasLabel(device.labels)) {
addDevice(device.id);
}
}
// A child device without its own area is in the area of its parent device
const deviceArea = (deviceId: string) => {
const device = hass.devices[deviceId];
return (
device?.area_id ??
(device?.parent_device_id
? hass.devices[device.parent_device_id]?.area_id
: undefined)
);
};
const areaDeviceIds = new Set<string>();
for (const device of Object.values(hass.devices)) {
const areaId = deviceArea(device.id);
if (areaId && areaIds.has(areaId)) {
areaDeviceIds.add(device.id);
}
}
const entityIds = new Set(ensureArray(target.entity_id ?? []));
for (const entity of Object.values(hass.entities)) {
// The display registry keeps the entities of a previous demo, and like
// core, hidden entities are never included indirectly
if (!(entity.entity_id in hass.states) || entity.hidden) {
continue;
}
const primary = !primaryEntitiesOnly || !entity.entity_category;
if (
hasLabel(entity.labels) ||
(primary &&
((entity.device_id && deviceIds.has(entity.device_id)) ||
(entity.area_id && areaIds.has(entity.area_id)) ||
// An entity with its own area is not in the area of its device
(!entity.area_id &&
entity.device_id &&
areaDeviceIds.has(entity.device_id))))
) {
entityIds.add(entity.entity_id);
}
}
return {
areas: [...areaIds],
devices: [...new Set([...deviceIds, ...areaDeviceIds])],
entities: [...entityIds],
missing,
};
};
const matchesFilter = (
hass: MockHomeAssistant,
entityId: string,
@@ -125,20 +228,27 @@ export const mockAutomationPlatforms = (hass: MockHomeAssistant) => {
);
mockForTarget(hass, "get_triggers_for_target", triggerDescriptions);
mockForTarget(hass, "get_conditions_for_target", conditionDescriptions);
mockExtractFromTarget(hass);
hass.mockWS(
"explain_target",
"extract_from_target",
(
msg: { target: HassServiceTarget; primary_entities_only?: boolean },
currentHass: MockHomeAssistant
) =>
explainTarget(
): ExtractFromTargetResult => {
const { areas, devices, entities, missing } = expandTarget(
currentHass,
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",
-122
View File
@@ -1,122 +0,0 @@
import type { CalendarEventApiData } from "../../../src/data/calendar";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
// Sample events relative to today, so demos always show something in the
// current week and month.
const pad = (value: number) => String(value).padStart(2, "0");
const day = (offset: number): Date => {
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + offset);
return date;
};
const isoDate = (date: Date) =>
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const at = (offset: number, hours: number, minutes = 0) => {
const date = day(offset);
date.setHours(hours, minutes);
return date.toISOString();
};
const allDay = (offset: number, days = 1) => ({
start: isoDate(day(offset)),
end: isoDate(day(offset + days)),
all_day: true,
});
export const FAMILY_EVENTS: CalendarEventApiData[] = [
{ summary: "School run", start: at(0, 8), end: at(0, 8, 30) },
{
summary: "Dentist",
start: at(0, 15, 30),
end: at(0, 16, 15),
location: "Main Street 12",
},
{ summary: "Grandma's birthday", ...allDay(1) },
{ summary: "Football practice", start: at(2, 17), end: at(2, 18, 30) },
{
summary: "Dinner with friends",
start: at(3, 19),
end: at(3, 22),
status: "tentative",
},
{ summary: "Summer cabin", ...allDay(5, 3) },
{ summary: "Parent-teacher meeting", start: at(9, 18), end: at(9, 19) },
{ summary: "Football practice", start: at(9, 17), end: at(9, 18, 30) },
{ summary: "Swimming lessons", start: at(12, 10), end: at(12, 11) },
{ summary: "Car service", start: at(-3, 9), end: at(-3, 11) },
{ summary: "Book club", start: at(-6, 19), end: at(-6, 21) },
];
export const WORK_EVENTS: CalendarEventApiData[] = [
{ summary: "Stand-up", start: at(0, 9), end: at(0, 9, 15) },
{ summary: "Sprint planning", start: at(2, 10), end: at(2, 12) },
{ summary: "Team lunch", start: at(4, 12), end: at(4, 13) },
{ summary: "Conference", ...allDay(8, 2) },
{ summary: "Quarterly review", start: at(-2, 14), end: at(-2, 15, 30) },
];
const toTime = (value: CalendarEventApiData["start"]) =>
new Date(
typeof value === "string"
? value
: "dateTime" in value
? value.dateTime
: value.date
).getTime();
const inRange = (
events: CalendarEventApiData[],
start: string | null,
end: string | null
): CalendarEventApiData[] => {
const startTime = start ? new Date(start).getTime() : -Infinity;
const endTime = end ? new Date(end).getTime() : Infinity;
return events.filter(
(event) => toTime(event.end) > startTime && toTime(event.start) < endTime
);
};
/**
* Answers event requests of calendar entities with the events of
* `getEvents`. A calendar without events gets `null`, like a calendar whose
* integration fails to return them.
*/
export const mockCalendarEvents = (
hass: MockHomeAssistant,
getEvents: (
entityId: string,
currentHass: MockHomeAssistant
) => CalendarEventApiData[] | null | undefined
) => {
hass.mockWS("calendar/event/subscribe", (msg, currentHass, onChange) => {
const events = getEvents(msg.entity_id, currentHass);
onChange?.({ events: events ? inRange(events, msg.start, msg.end) : null });
return () => undefined;
});
// Home Assistant before 2026.5 has no event subscription, so cards that
// support it ask the REST API instead.
hass.mockAPI(/^calendars\/[^/?]+\?/, (currentHass, _method, path) => {
const url = new URL(path, location.origin);
const entityId = url.pathname.slice(url.pathname.lastIndexOf("/") + 1);
const events = getEvents(entityId, currentHass);
if (!events) {
return Promise.reject(new Error("Calendar not found"));
}
return inRange(
events,
url.searchParams.get("start"),
url.searchParams.get("end")
);
});
};
@@ -59,10 +59,6 @@ const REGISTRY_ENTRIES = [
export const infraredFixtures: ConnectivityFixtures = {
components: ["infrared"],
manifests: [
manifest("infrared", "Infrared", {
config_flow: false,
integration_type: "entity",
}),
manifest("broadlink", "Broadlink", {
integration_type: "hub",
iot_class: "local_polling",
@@ -44,21 +44,14 @@ export const lorawanFixtures: ConnectivityFixtures = {
],
commands: ["lorawan/"],
manifests: [
manifest("lorawan", "LoRaWAN", {
config_flow: false,
integration_type: "service",
}),
manifest("lorawan", "LoRaWAN", { integration_type: "helper" }),
manifest("sensecap_lorawan", "SenseCAP", {
integration_type: "device",
dependencies: ["lorawan"],
}),
manifest("chirpstack", "ChirpStack", {
integration_type: "hub",
dependencies: ["lorawan"],
}),
manifest("chirpstack", "ChirpStack", { integration_type: "hub" }),
manifest("thethingsnetwork_lorawan", "The Things Network", {
integration_type: "hub",
dependencies: ["lorawan"],
}),
],
configEntries: [
@@ -10,26 +10,19 @@ export const FRONIUS_ENTRY_ID = "mock-fronius";
export const RS485_PORT = "/dev/ttyUSB1";
export const modbusFixtures: ConnectivityFixtures = {
components: ["modbus", "flexit", "solaredge_modbus", "fronius"],
components: ["modbus"],
commands: ["modbus/"],
manifests: [
manifest("modbus", "Modbus", {
config_flow: false,
iot_class: "local_polling",
}),
manifest("flexit", "Flexit", {
integration_type: "device",
dependencies: ["modbus"],
iot_class: "local_polling",
}),
manifest("solaredge_modbus", "SolarEdge Modbus", {
integration_type: "device",
dependencies: ["modbus"],
iot_class: "local_polling",
}),
manifest("fronius", "Fronius", {
integration_type: "device",
dependencies: ["modbus"],
iot_class: "local_polling",
}),
],
@@ -42,13 +42,7 @@ const REGISTRY_ENTRIES = [
export const radioFrequencyFixtures: ConnectivityFixtures = {
components: ["radio_frequency"],
commands: ["radio_frequency/"],
manifests: [
manifest("radio_frequency", "Radio frequency", {
config_flow: false,
integration_type: "entity",
}),
manifest("esphome", "ESPHome", { integration_type: "device" }),
],
manifests: [manifest("esphome", "ESPHome", { integration_type: "device" })],
configEntries: [
{ type: "device", entry: configEntry(ENTRY_ID, "esphome", "RF Bridge") },
],
+9 -11
View File
@@ -15,7 +15,6 @@ import type {
ZHANetworkBackup,
ZHANetworkSettings,
} from "../../../../../src/data/zha";
import { ZHADeviceType } from "../../../../../src/data/zha";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { minutesAgo } from "../helpers";
@@ -57,7 +56,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-coordinator",
user_given_name: "Home Assistant Connect ZBT-1",
power_source: "Mains",
device_type: ZHADeviceType.Coordinator,
device_type: "Coordinator",
active_coordinator: true,
signature: {},
neighbors: [
@@ -84,7 +83,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-porch-light",
user_given_name: "Porch light",
power_source: "Mains",
device_type: ZHADeviceType.Router,
device_type: "Router",
active_coordinator: false,
signature: {},
neighbors: [
@@ -111,7 +110,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-hall-motion",
user_given_name: "Hall motion",
power_source: "Battery",
device_type: ZHADeviceType.EndDevice,
device_type: "EndDevice",
active_coordinator: false,
signature: {},
neighbors: [neighbor(PORCH_IEEE, "0x1a2b", "142", "Parent", "1")],
@@ -134,7 +133,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-tv-plug",
user_given_name: "TV plug",
power_source: "Mains",
device_type: ZHADeviceType.Router,
device_type: "Router",
active_coordinator: false,
signature: {},
neighbors: [neighbor(COORDINATOR_IEEE, "0x0000", "198", "Parent", "0")],
@@ -157,7 +156,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-kitchen-switch",
user_given_name: "Kitchen switch",
power_source: "Battery",
device_type: ZHADeviceType.EndDevice,
device_type: "EndDevice",
active_coordinator: false,
signature: {},
neighbors: [neighbor(PORCH_IEEE, "0x1a2b", "186", "Parent", "1")],
@@ -180,7 +179,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-landing-sensor",
user_given_name: "Landing sensor",
power_source: "Battery",
device_type: ZHADeviceType.EndDevice,
device_type: "EndDevice",
active_coordinator: false,
signature: {},
neighbors: [neighbor(OFFICE_IEEE, "0x7a8b", "164", "Parent", "2")],
@@ -203,7 +202,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-garage-contact",
user_given_name: "Garage contact",
power_source: "Battery",
device_type: ZHADeviceType.EndDevice,
device_type: "EndDevice",
active_coordinator: false,
signature: {},
neighbors: [neighbor(OFFICE_IEEE, "0x7a8b", "118", "Parent", "2")],
@@ -226,7 +225,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-office-plug",
user_given_name: "Office plug",
power_source: "Mains",
device_type: ZHADeviceType.Router,
device_type: "Router",
active_coordinator: false,
signature: {},
neighbors: [
@@ -594,8 +593,7 @@ export const mockZha = (hass: MockHomeAssistant) => {
hass.mockWS("zha/devices/bindable", (msg: { ieee: string }) =>
DEVICES.filter(
(candidate) =>
candidate.device_type === ZHADeviceType.Router &&
candidate.ieee !== msg.ieee
candidate.device_type === "Router" && candidate.ieee !== msg.ieee
)
);
hass.mockWS("zha/devices/bind", () => undefined);
-54
View File
@@ -1,60 +1,6 @@
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[] = []
+19 -1
View File
@@ -1,6 +1,24 @@
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import { connectivityDevices } from "./connectivity/fixtures";
import { baseDevice } from "./device_registry";
const baseDevice = {
config_entries_subentries: {},
connections: [] as [string, string][],
identifiers: [] as [string, string][],
model_id: null,
labels: [] as string[],
sw_version: null,
hw_version: null,
serial_number: null,
via_device_id: null,
area_id: null,
name_by_user: null,
disabled_by: null,
configuration_url: null,
parent_device_id: null,
created_at: 0,
modified_at: 0,
};
export const demoDevices: DeviceRegistryEntry[] = [
{
-9
View File
@@ -97,14 +97,6 @@ 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: {},
@@ -114,7 +106,6 @@ 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",
+20 -40
View File
@@ -11,7 +11,7 @@ export const energyEntities = () =>
entity_id: "sensor.solar_production",
state: "88.6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Solar",
unit_of_measurement: "kWh",
},
@@ -20,7 +20,7 @@ export const energyEntities = () =>
entity_id: "sensor.battery_input",
state: "4",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Battery Input",
unit_of_measurement: "kWh",
},
@@ -29,7 +29,7 @@ export const energyEntities = () =>
entity_id: "sensor.battery_output",
state: "3",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Battery Output",
unit_of_measurement: "kWh",
},
@@ -38,7 +38,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_consumption_tarif_1",
state: "88.6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Grid consumption low tariff",
unit_of_measurement: "kWh",
},
@@ -47,7 +47,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_consumption_tarif_2",
state: "88.6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Grid consumption high tariff",
unit_of_measurement: "kWh",
},
@@ -56,7 +56,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_production_tarif_1",
state: "88.6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Returned to grid low tariff",
unit_of_measurement: "kWh",
},
@@ -65,7 +65,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_production_tarif_2",
state: "88.6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Returned to grid high tariff",
unit_of_measurement: "kWh",
},
@@ -74,7 +74,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_consumption_tarif_1_cost",
state: "2",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
unit_of_measurement: "EUR",
},
},
@@ -82,7 +82,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_consumption_tarif_2_cost",
state: "2",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
unit_of_measurement: "EUR",
},
},
@@ -90,7 +90,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_production_tarif_1_compensation",
state: "2",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
unit_of_measurement: "EUR",
},
},
@@ -98,7 +98,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_production_tarif_2_compensation",
state: "2",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
unit_of_measurement: "EUR",
},
},
@@ -138,7 +138,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_gas_cost",
state: "2",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
unit_of_measurement: "EUR",
},
},
@@ -146,7 +146,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_gas",
state: "4",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Gas",
unit_of_measurement: "m³",
},
@@ -155,36 +155,16 @@ export const energyEntities = () =>
entity_id: "sensor.energy_water",
state: "4000",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Water",
unit_of_measurement: "L",
},
},
"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: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Electric car",
unit_of_measurement: "kWh",
},
@@ -193,7 +173,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_ac",
state: "3",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Air conditioning",
unit_of_measurement: "kWh",
},
@@ -202,7 +182,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_washing_machine",
state: "6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Washing machine",
unit_of_measurement: "kWh",
},
@@ -211,7 +191,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_dryer",
state: "5.5",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Dryer",
unit_of_measurement: "kWh",
},
@@ -220,7 +200,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_heat_pump",
state: "6",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Heat pump",
unit_of_measurement: "kWh",
},
@@ -229,7 +209,7 @@ export const energyEntities = () =>
entity_id: "sensor.energy_boiler",
state: "7",
attributes: {
state_class: "total_increasing",
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Boiler",
unit_of_measurement: "kWh",
},
+2 -14
View File
@@ -8,7 +8,7 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
let entries: EntityRegistryEntry[] = [];
export const newEntry = (
const newEntry = (
entry: Pick<EntityRegistryEntry, "entity_id" | "platform" | "unique_id">
): EntityRegistryEntry => {
const now = Date.now() / 1000;
@@ -43,9 +43,7 @@ const displayFields = (entry: EntityRegistryEntry) => ({
hidden: entry.hidden_by !== null,
});
export const extEntry = (
entry: EntityRegistryEntry
): ExtEntityRegistryEntry => ({
const extEntry = (entry: EntityRegistryEntry): ExtEntityRegistryEntry => ({
...entry,
capabilities: {},
aliases: [],
@@ -58,16 +56,6 @@ export const mockEntityRegistry = (
entries = [...data];
// A new array, so subscribers see the registry changed after an update
hass.mockWS("config/entity_registry/list", () => [...entries]);
hass.mockWS(
"config/entity_registry/get",
(msg: { entity_id: string }): ExtEntityRegistryEntry => {
const entry = entries.find((e) => e.entity_id === msg.entity_id);
if (!entry) {
throw new Error(`Entity not found: ${msg.entity_id}`);
}
return extEntry(entry);
}
);
hass.mockWS(
"config/entity_registry/get_entries",
(msg: { entity_ids: string[] }) => {
-79
View File
@@ -1,79 +0,0 @@
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),
};
}),
};
};
-15
View File
@@ -1,15 +0,0 @@
import type { LogbookStreamMessage } from "../../../src/data/logbook";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
/**
* Answers logbook subscriptions with an empty logbook. No entries come from
* an automation or script trace.
*/
export const mockLogbook = (hass: MockHomeAssistant) => {
hass.mockWS("logbook/event_stream", (_msg, _currentHass, onChange) => {
onChange?.({ events: [] } satisfies LogbookStreamMessage);
return () => undefined;
});
hass.mockWS("trace/contexts", () => ({}));
};
-1
View File
@@ -149,7 +149,6 @@ const repositories: RepositoryBase[] = [
const repositoryInfo = (repository: RepositoryBase): RepositoryInfo => ({
...repository,
additional_info: `# ${repository.name}\n\n${repository.description}\n\nThis is a demo. Installing is not available.`,
beta: false,
default_branch: "main",
issues: 12,
releases: [repository.available_version],
+1 -21
View File
@@ -158,24 +158,6 @@ 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,
@@ -390,9 +372,7 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
const entityState = hass.states[id];
const state = entityState ? Number(entityState.state) : 1;
statistics[id] =
entityState &&
(entityState.attributes.state_class === "total" ||
entityState.attributes.state_class === "total_increasing")
entityState && "last_reset" in entityState.attributes
? generateSumStatistics(
start,
end,
-132
View File
@@ -1,132 +0,0 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../../../src/common/array/ensure-array";
import type { ExtractFromTargetResult } from "../../../src/data/target";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { getLabelIds } from "./label_registry";
// Expands a target to its areas, devices and entities, like core does
export const expandTarget = (
hass: MockHomeAssistant,
target: HassServiceTarget,
primaryEntitiesOnly = true
) => {
const missing = {
floors: [] as string[],
areas: [] as string[],
devices: [] as string[],
labels: [] as string[],
};
const knownLabelIds = getLabelIds();
const floorIds = ensureArray(target.floor_id ?? []);
const labelIds = ensureArray(target.label_id ?? []);
missing.floors.push(...floorIds.filter((id) => !(id in hass.floors)));
// Like core, unknown labels are reported, but still expanded
missing.labels.push(...labelIds.filter((id) => !knownLabelIds.includes(id)));
const hasLabel = (labels?: string[]) =>
labelIds.some((labelId) => labels?.includes(labelId));
// Unknown areas and devices are reported, but still referenced
const areaIds = new Set(ensureArray(target.area_id ?? []));
missing.areas.push(...[...areaIds].filter((id) => !(id in hass.areas)));
for (const area of Object.values(hass.areas)) {
if (
(area.floor_id && floorIds.includes(area.floor_id)) ||
hasLabel(area.labels)
) {
areaIds.add(area.area_id);
}
}
// A targeted or labeled device includes its child devices
const deviceIds = new Set<string>();
const addDevice = (deviceId: string) => {
deviceIds.add(deviceId);
for (const device of Object.values(hass.devices)) {
if (device.parent_device_id === deviceId) {
deviceIds.add(device.id);
}
}
};
for (const deviceId of ensureArray(target.device_id ?? [])) {
if (!(deviceId in hass.devices)) {
missing.devices.push(deviceId);
}
addDevice(deviceId);
}
for (const device of Object.values(hass.devices)) {
if (hasLabel(device.labels)) {
addDevice(device.id);
}
}
// A child device without its own area is in the area of its parent device
const deviceArea = (deviceId: string) => {
const device = hass.devices[deviceId];
return (
device?.area_id ??
(device?.parent_device_id
? hass.devices[device.parent_device_id]?.area_id
: undefined)
);
};
const areaDeviceIds = new Set<string>();
for (const device of Object.values(hass.devices)) {
const areaId = deviceArea(device.id);
if (areaId && areaIds.has(areaId)) {
areaDeviceIds.add(device.id);
}
}
const entityIds = new Set(ensureArray(target.entity_id ?? []));
for (const entity of Object.values(hass.entities)) {
// The display registry keeps the entities of a previous demo, and like
// core, hidden entities are never included indirectly
if (!(entity.entity_id in hass.states) || entity.hidden) {
continue;
}
const primary = !primaryEntitiesOnly || !entity.entity_category;
if (
hasLabel(entity.labels) ||
(primary &&
((entity.device_id && deviceIds.has(entity.device_id)) ||
(entity.area_id && areaIds.has(entity.area_id)) ||
// An entity with its own area is not in the area of its device
(!entity.area_id &&
entity.device_id &&
areaDeviceIds.has(entity.device_id))))
) {
entityIds.add(entity.entity_id);
}
}
return {
areas: [...areaIds],
devices: [...new Set([...deviceIds, ...areaDeviceIds])],
entities: [...entityIds],
missing,
};
};
export const mockExtractFromTarget = (hass: MockHomeAssistant) =>
hass.mockWS(
"extract_from_target",
(
msg: { target: HassServiceTarget; primary_entities_only?: boolean },
currentHass: MockHomeAssistant
): ExtractFromTargetResult => {
const { areas, devices, entities, missing } = expandTarget(
currentHass,
msg.target,
msg.primary_entities_only ?? true
);
return {
missing_areas: missing.areas,
missing_devices: missing.devices,
missing_floors: missing.floors,
missing_labels: missing.labels,
referenced_areas: areas,
referenced_devices: devices,
referenced_entities: entities,
};
}
);
-133
View File
@@ -1,133 +0,0 @@
import type {
ForecastAttribute,
ModernForecastType,
} from "../../../src/data/weather";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const HOUR = 3600000;
const CONDITIONS = [
"partlycloudy",
"rainy",
"cloudy",
"sunny",
"pouring",
"partlycloudy",
"sunny",
];
// Daily changes around the current temperature, so the forecast looks real.
const TEMPERATURE_CHANGES = [0, -1.5, -3, -1, 1.5, 2.5, 1];
const PRECIPITATION = new Map([
["rainy", 2.4],
["pouring", 9.1],
]);
const forecastEntry = (
datetime: Date,
temperature: number,
condition: string
): ForecastAttribute => ({
datetime: datetime.toISOString(),
condition,
temperature: Math.round(temperature * 10) / 10,
precipitation: PRECIPITATION.get(condition) ?? 0,
precipitation_probability: PRECIPITATION.has(condition) ? 80 : 10,
});
const generateForecast = (
forecastType: ModernForecastType,
temperature: number,
condition: string
): ForecastAttribute[] => {
const conditions = [condition, ...CONDITIONS.slice(1)];
const now = new Date();
if (forecastType === "hourly") {
now.setMinutes(0, 0, 0);
return Array.from({ length: 24 }, (_, hour) => {
const datetime = new Date(now.getTime() + (hour + 1) * HOUR);
// Warmest mid-afternoon and coldest before dawn
const change =
3 * Math.sin(((datetime.getHours() - 9) / 24) * 2 * Math.PI) -
3 * Math.sin(((now.getHours() - 9) / 24) * 2 * Math.PI);
return forecastEntry(
datetime,
temperature + change,
conditions[Math.floor(hour / 6)]
);
});
}
now.setHours(12, 0, 0, 0);
if (forecastType === "twice_daily") {
return conditions.flatMap((dayCondition, day) => {
const datetime = new Date(now.getTime() + day * 24 * HOUR);
const dayTemperature = temperature + TEMPERATURE_CHANGES[day];
return [
{
...forecastEntry(datetime, dayTemperature, dayCondition),
is_daytime: true,
},
{
...forecastEntry(
new Date(datetime.getTime() + 12 * HOUR),
dayTemperature - 6,
dayCondition
),
is_daytime: false,
},
];
});
}
return conditions.map((dayCondition, day) => {
const dayTemperature = temperature + TEMPERATURE_CHANGES[day];
return {
...forecastEntry(
new Date(now.getTime() + day * 24 * HOUR),
dayTemperature,
dayCondition
),
templow: Math.round((dayTemperature - 6) * 10) / 10,
};
});
};
/**
* Answers forecast subscriptions of weather entities. An entity with a
* `forecast` attribute gets that forecast. Other entities get a forecast made
* up from their condition and temperature.
*/
export const mockWeather = (hass: MockHomeAssistant) => {
hass.mockWS(
"weather/subscribe_forecast",
(
msg: { entity_id: string; forecast_type: ModernForecastType },
currentHass,
onChange
) => {
const stateObj = currentHass.states[msg.entity_id];
onChange?.({
type: msg.forecast_type,
forecast: stateObj
? (stateObj.attributes.forecast ??
generateForecast(
msg.forecast_type,
Number(stateObj.attributes.temperature ?? 15),
stateObj.state
))
: null,
});
return () => undefined;
}
);
};
-8
View File
@@ -301,14 +301,6 @@ 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,
-4
View File
@@ -81,10 +81,6 @@ 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"],
+77 -10
View File
@@ -1,14 +1,68 @@
import {
FAMILY_EVENTS,
mockCalendarEvents,
WORK_EVENTS,
} from "../../../demo/src/stubs/calendar";
import { mockEntityRegistry } from "../../../demo/src/stubs/entity_registry";
import type { CalendarEventApiData } from "../../../src/data/calendar";
import { CalendarEntityFeature } from "../../../src/data/calendar";
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
// Sample calendars with events relative to today, so demos always show
// something in the current week and month.
const pad = (value: number) => String(value).padStart(2, "0");
const day = (offset: number): Date => {
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + offset);
return date;
};
const isoDate = (date: Date) =>
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const at = (offset: number, hours: number, minutes = 0) => {
const date = day(offset);
date.setHours(hours, minutes);
return date.toISOString();
};
const allDay = (offset: number, days = 1) => ({
start: isoDate(day(offset)),
end: isoDate(day(offset + days)),
all_day: true,
});
const FAMILY_EVENTS: CalendarEventApiData[] = [
{ summary: "School run", start: at(0, 8), end: at(0, 8, 30) },
{
summary: "Dentist",
start: at(0, 15, 30),
end: at(0, 16, 15),
location: "Main Street 12",
},
{ summary: "Grandma's birthday", ...allDay(1) },
{ summary: "Football practice", start: at(2, 17), end: at(2, 18, 30) },
{
summary: "Dinner with friends",
start: at(3, 19),
end: at(3, 22),
status: "tentative",
},
{ summary: "Summer cabin", ...allDay(5, 3) },
{ summary: "Parent-teacher meeting", start: at(9, 18), end: at(9, 19) },
{ summary: "Football practice", start: at(9, 17), end: at(9, 18, 30) },
{ summary: "Swimming lessons", start: at(12, 10), end: at(12, 11) },
{ summary: "Car service", start: at(-3, 9), end: at(-3, 11) },
{ summary: "Book club", start: at(-6, 19), end: at(-6, 21) },
];
const WORK_EVENTS: CalendarEventApiData[] = [
{ summary: "Stand-up", start: at(0, 9), end: at(0, 9, 15) },
{ summary: "Sprint planning", start: at(2, 10), end: at(2, 12) },
{ summary: "Team lunch", start: at(4, 12), end: at(4, 13) },
{ summary: "Conference", ...allDay(8, 2) },
{ summary: "Quarterly review", start: at(-2, 14), end: at(-2, 15, 30) },
];
const EVENTS: Record<string, CalendarEventApiData[] | null> = {
"calendar.family": FAMILY_EVENTS,
"calendar.work": WORK_EVENTS,
@@ -25,8 +79,8 @@ export const CALENDAR_ENTITIES = [
friendly_name: "Family",
message: "School run",
all_day: false,
start_time: FAMILY_EVENTS[0].start,
end_time: FAMILY_EVENTS[0].end,
start_time: at(0, 8),
end_time: at(0, 8, 30),
location: "",
description: "",
supported_features:
@@ -42,8 +96,8 @@ export const CALENDAR_ENTITIES = [
friendly_name: "Work",
message: "Stand-up",
all_day: false,
start_time: WORK_EVENTS[0].start,
end_time: WORK_EVENTS[0].end,
start_time: at(0, 9),
end_time: at(0, 9, 15),
},
},
{
@@ -91,7 +145,20 @@ export const mockCalendars = (hass: MockHomeAssistant) => {
registryEntry("calendar.empty"),
registryEntry("calendar.holidays"),
]);
mockCalendarEvents(hass, (entityId) => EVENTS[entityId]);
hass.mockWS("calendar/event/subscribe", (msg, _hass, onChange) => {
const start = new Date(msg.start).getTime();
const end = new Date(msg.end).getTime();
const events = EVENTS[msg.entity_id];
onChange?.({
events:
events?.filter(
(event) =>
new Date(event.end as string).getTime() > start &&
new Date(event.start as string).getTime() < end
) ?? null,
});
return () => undefined;
});
// Changes made in the event dialogs are accepted but not stored
hass.mockWS("calendar/event/create", () => null);
hass.mockWS("calendar/event/update", () => null);
-30
View File
@@ -48,36 +48,6 @@ export const createMediaPlayerEntities = () => [
group_members: ["media_player.playing", "media_player.stream_playing"],
},
},
{
entity_id: "media_player.music_all_features",
state: "playing",
attributes: {
friendly_name: "Playing The Music (all features)",
media_content_type: "music",
media_title: "I Wanna Be A Hippy (Flamman & Abraxas Radio Mix)",
media_artist: "Technohead",
// Pause + Seek + Volume Set + Volume Mute + Previous Track + Next Track + On + Off +
// Play Media + Volume Step + Select Source + Stop + Clear + Play + Shuffle Set +
// Sound Mode + Browse Media + Repeat Set + Grouping
supported_features: 1048511,
entity_picture: "/images/album_cover.jpg",
media_duration: 300,
media_position: 0,
media_position_updated_at: new Date(
// 23 seconds in
new Date().getTime() - 23000
).toISOString(),
volume_level: 0.5,
is_volume_muted: false,
shuffle: false,
repeat: "off",
source_list: ["AirPlay", "Blu-Ray", "TV", "USB", "iPod (USB)"],
source: "AirPlay",
sound_mode_list: ["Movie", "Music", "Game", "Pure Audio"],
sound_mode: "Music",
group_members: ["media_player.playing", "media_player.stream_playing"],
},
},
{
entity_id: "media_player.stream_playing",
state: "playing",
+2 -2
View File
@@ -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 Home 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 Assistant 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 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.
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.
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.
@@ -1,21 +1,25 @@
import { mdiFan, mdiFanSpeed1, mdiFanSpeed2, mdiFanSpeed3 } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators";
import { LitElement, css, html, nothing } from "lit";
import { customElement } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-card";
import type { HaDropdownSelectEvent } from "../../../../src/components/ha-dropdown";
import "../../../../src/components/ha-control-select-menu";
import type { SelectOption } from "../../../../src/components/ha-control-select-menu";
import "../../../../src/components/ha-list-item";
import "../../../../src/components/ha-svg-icon";
interface SelectMenuOptions {
label: string;
value: string;
icon?: string;
}
interface SelectMenu {
label: string;
icon: string;
class?: string;
disabled?: boolean;
options: SelectOption[];
options: SelectMenuOptions[];
}
const selects: SelectMenu[] = [
@@ -44,17 +48,17 @@ const selects: SelectMenu[] = [
{
value: "low",
label: "Low",
iconPath: mdiFanSpeed1,
icon: mdiFanSpeed1,
},
{
value: "medium",
label: "Medium",
iconPath: mdiFanSpeed2,
icon: mdiFanSpeed2,
},
{
value: "high",
label: "High",
iconPath: mdiFanSpeed3,
icon: mdiFanSpeed3,
},
],
},
@@ -68,8 +72,6 @@ const selects: SelectMenu[] = [
@customElement("demo-components-ha-control-select-menu")
export class DemoHaControlSelectMenu extends LitElement {
@state() private _values: Record<string, string> = {};
protected render(): TemplateResult {
return html`
<ha-card>
@@ -79,13 +81,31 @@ export class DemoHaControlSelectMenu extends LitElement {
<div class="card-content">
<ha-control-select-menu
.label=${select.label}
.value=${this._values[select.label]}
.options=${select.options}
?disabled=${select.disabled}
data-label=${select.label}
@wa-select=${this._handleSelect}
fixedMenuPosition
naturalMenuWidth
>
<ha-svg-icon slot="icon" .path=${select.icon}></ha-svg-icon>
${select.options.map(
(option) => html`
<ha-list-item
.value=${option.value}
.graphic=${option.icon ? "icon" : undefined}
>
${
option.icon
? html`
<ha-svg-icon
slot="graphic"
.path=${option.icon}
></ha-svg-icon>
`
: nothing
}
${option.label ?? option.value}
</ha-list-item>
`
)}
</ha-control-select-menu>
</div>
`
@@ -94,17 +114,6 @@ export class DemoHaControlSelectMenu extends LitElement {
`;
}
private _handleSelect(
ev: HaDropdownSelectEvent & HASSDomCurrentTargetEvent<HTMLElement>
) {
const label = ev.currentTarget.dataset.label;
const value = ev.detail.item.value;
if (!label || !value) {
return;
}
this._values = { ...this._values, [label]: value };
}
static styles = css`
ha-card {
max-width: 600px;
@@ -50,18 +50,6 @@ With no cursor yet, <kbd>↓</kbd> and <kbd>Enter</kbd> start from the `value`
row, or from the top match while searching. With one item in the list,
<kbd>Enter</kbd> picks it whether or not the cursor has moved.
## Screen readers
DOM focus stays in the search field, so screen readers do not hear the list
change. While searching, a visually hidden `role="status"` message says how
many items match the search and names it, so a new search with the same count
is still read out. It is cleared as soon as the list changes and filled in once
typing pauses for half a second, so it doesn't talk over each typed character
or describe the previous search. When nothing matches, it reads a not
found label that takes the search, or says there are no results for it. Section
titles, placeholder rows, "Add custom item", the additional items and rows that
`isSearchResult` rejects are not counted.
## Implementation
Without `sections`, the component filters the items from `getItems` itself as the
@@ -91,7 +79,6 @@ means supplying one.
| allowCustomValue | Boolean | false | Offers the typed string as an item. |
| customValueLabel | String | - | Label for that custom item. |
| notFoundLabel | String/Function | - | Shown when a search matches nothing. |
| isSearchResult | Function | - | Whether a row from `getItems` counts as a match for screen readers. The quick bar leaves out its "Ask Assist" row. |
| emptyLabel | String | - | Shown when there are no items at all. |
| noSort | Boolean | false | Keeps the order `getItems` returned. |
| clearable | Boolean | false | Adjusts search field padding for a clear affordance. |
@@ -1,9 +1,7 @@
import { ContextProvider } from "@lit/context";
import { IntlMessageFormat } from "intl-messageformat";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import type { LocalizeFunc } from "../../../../src/common/translations/localize";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-picker-combo-box";
import type { PickerComboBoxItem } from "../../../../src/components/ha-picker-combo-box";
@@ -21,26 +19,13 @@ import type {
} from "../../../../src/types";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
// The English strings, plurals included, so searching a demo announces the
// same count and search as the real picker.
const LOCALIZE_KEYS: Record<string, string> = {
"ui.common.search": "Search",
"ui.components.combo-box.no_match": "No matching items found",
"ui.components.combo-box.no_items": "No items available",
"ui.components.combo-box.result_count":
"{count} {count, plural,\n one {result}\n other {results}\n} for {term}",
"ui.components.combo-box.no_results": "No results for {term}",
};
const localize: LocalizeFunc = (key, values) => {
const message = LOCALIZE_KEYS[key];
if (message === undefined) {
return key;
}
return new IntlMessageFormat(message, "en").format<string>(
values as Record<string, string | number>
) as string;
};
const localize = (key: string) => LOCALIZE_KEYS[key] ?? key;
const DEMO_I18N: HomeAssistantInternationalization = {
localize,
+2 -7
View File
@@ -683,13 +683,8 @@ const SCHEMAS: {
name: "Unit of measurement",
input: {
sensor_device_class: {
name: "Device class of the unit below",
selector: {
device_class: {
domain: "sensor",
context: { filter_unit_of_measurement: "unit_linked" },
},
},
name: "Device class",
selector: { device_class: { domain: "sensor" } },
},
sensor_state_class: {
name: "State class",
@@ -5,7 +5,6 @@ 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";
@@ -226,20 +225,6 @@ 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);
}
@@ -1,6 +0,0 @@
---
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.
@@ -1,211 +0,0 @@
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;
}
}
@@ -1,6 +0,0 @@
---
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.
@@ -1,212 +0,0 @@
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;
}
}
@@ -3,6 +3,7 @@ import { customElement, state } from "lit/decorators";
import { formatDateTimeNumeric } from "../../../../src/common/datetime/format_date_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -48,7 +49,7 @@ export class DemoDateTimeDateTimeNumeric extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<div>
<ha-list>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -95,7 +96,7 @@ export class DemoDateTimeDateTimeNumeric extends LitElement {
</div>
`
)}
</div>
</ha-list>
`;
}
@@ -3,6 +3,7 @@ import { customElement, state } from "lit/decorators";
import { formatDateTimeWithSeconds } from "../../../../src/common/datetime/format_date_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -48,7 +49,7 @@ export class DemoDateTimeDateTimeSeconds extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<div>
<ha-list>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -95,7 +96,7 @@ export class DemoDateTimeDateTimeSeconds extends LitElement {
</div>
`
)}
</div>
</ha-list>
`;
}
@@ -3,6 +3,7 @@ import { customElement, state } from "lit/decorators";
import { formatShortDateTimeWithYear } from "../../../../src/common/datetime/format_date_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -48,7 +49,7 @@ export class DemoDateTimeDateTimeShortYear extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<div>
<ha-list>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -95,7 +96,7 @@ export class DemoDateTimeDateTimeShortYear extends LitElement {
</div>
`
)}
</div>
</ha-list>
`;
}
@@ -3,6 +3,7 @@ import { customElement, state } from "lit/decorators";
import { formatShortDateTime } from "../../../../src/common/datetime/format_date_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -48,7 +49,7 @@ export class DemoDateTimeDateTimeShort extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<div>
<ha-list>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -95,7 +96,7 @@ export class DemoDateTimeDateTimeShort extends LitElement {
</div>
`
)}
</div>
</ha-list>
`;
}
+3 -2
View File
@@ -3,6 +3,7 @@ import { customElement, state } from "lit/decorators";
import { formatDateTime } from "../../../../src/common/datetime/format_date_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -48,7 +49,7 @@ export class DemoDateTimeDateTime extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<div>
<ha-list>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -95,7 +96,7 @@ export class DemoDateTimeDateTime extends LitElement {
</div>
`
)}
</div>
</ha-list>
`;
}
+3 -2
View File
@@ -2,6 +2,7 @@ import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { formatDateNumeric } from "../../../../src/common/datetime/format_date";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -26,7 +27,7 @@ export class DemoDateTimeDate extends LitElement {
};
const date = new Date();
return html`
<div>
<ha-list>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -85,7 +86,7 @@ export class DemoDateTimeDate extends LitElement {
</div>
`
)}
</div>
</ha-list>
`;
}
+3 -2
View File
@@ -3,6 +3,7 @@ import { customElement, state } from "lit/decorators";
import { formatTimeWithSeconds } from "../../../../src/common/datetime/format_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -48,7 +49,7 @@ export class DemoDateTimeTimeSeconds extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<div>
<ha-list>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -95,7 +96,7 @@ export class DemoDateTimeTimeSeconds extends LitElement {
</div>
`
)}
</div>
</ha-list>
`;
}
+3 -2
View File
@@ -3,6 +3,7 @@ import { customElement, state } from "lit/decorators";
import { formatTimeWeekday } from "../../../../src/common/datetime/format_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -48,7 +49,7 @@ export class DemoDateTimeTimeWeekday extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<div>
<ha-list>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -95,7 +96,7 @@ export class DemoDateTimeTimeWeekday extends LitElement {
</div>
`
)}
</div>
</ha-list>
`;
}
Loaded 100 of 791 files, more files were not shown because too many files have changed in this diff. Show more