mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-08 22:13:38 +00:00
Compare commits
191
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43edb1886f | ||
|
|
50437fd7c5 | ||
|
|
a74afc4e70 | ||
|
|
7175dc969c | ||
|
|
a2cdb48a59 | ||
|
|
7c38ade9ed | ||
|
|
983ff152e5 | ||
|
|
b6d761b03b | ||
|
|
4dc713abe6 | ||
|
|
b9182e3ea6 | ||
|
|
9bd57fa122 | ||
|
|
4af7503112 | ||
|
|
984e63dcd0 | ||
|
|
f90ed06080 | ||
|
|
b176b8226b | ||
|
|
3f3e9297f7 | ||
|
|
83028028e8 | ||
|
|
37d7c6144c | ||
|
|
f469f3eeae | ||
|
|
069e87c913 | ||
|
|
46bdbee8f3 | ||
|
|
68def911a6 | ||
|
|
a8fdb99317 | ||
|
|
3d1bf61683 | ||
|
|
5cfdff0e21 | ||
|
|
54cefeb816 | ||
|
|
4458205273 | ||
|
|
0c2de46f11 | ||
|
|
6bef900455 | ||
|
|
438bfa2710 | ||
|
|
96864e951a | ||
|
|
a1355e5116 | ||
|
|
ae4366020c | ||
|
|
7f73d29b2f | ||
|
|
ebf453ccc7 | ||
|
|
a0d9afe695 | ||
|
|
c42d048736 | ||
|
|
2f62ed5f73 | ||
|
|
8b6caeecdd | ||
|
|
4800b4f7e9 | ||
|
|
c7afc3391c | ||
|
|
3e9490e547 | ||
|
|
7a1cd6bbfe | ||
|
|
a2c3147d33 | ||
|
|
7039e3ad9f | ||
|
|
e97745d7d5 | ||
|
|
790c957405 | ||
|
|
f740091579 | ||
|
|
f76c88dd9f | ||
|
|
a428d61cdb | ||
|
|
fcf7eeae46 | ||
|
|
6d2817b7cf | ||
|
|
640e4e2f94 | ||
|
|
6670139c02 | ||
|
|
93eb44b041 | ||
|
|
ba6067f048 | ||
|
|
cd866a497a | ||
|
|
98a8483426 | ||
|
|
33af67bafe | ||
|
|
027d9578ec | ||
|
|
5fef8f3a68 | ||
|
|
18e65c1940 | ||
|
|
29906d6f83 | ||
|
|
952c227370 | ||
|
|
173c60e57b | ||
|
|
ef134330f3 | ||
|
|
c75386ab33 | ||
|
|
61b433f912 | ||
|
|
16ed504c66 | ||
|
|
e9550ffe52 | ||
|
|
2c6cf8e45b | ||
|
|
0dcfc247a7 | ||
|
|
2ab1f187c5 | ||
|
|
56d6f1c571 | ||
|
|
f169ae79f5 | ||
|
|
599089a2d3 | ||
|
|
2af451338f | ||
|
|
2ba9a1fc61 | ||
|
|
96e525f7a3 | ||
|
|
c42dac197f | ||
|
|
8fbae6346d | ||
|
|
2a69518dba | ||
|
|
960bf08c84 | ||
|
|
6c2bc65f07 | ||
|
|
e52547e374 | ||
|
|
7474e3e342 | ||
|
|
8dbd1527d9 | ||
|
|
d9d849c843 | ||
|
|
4f73b43912 | ||
|
|
a6fb28dfef | ||
|
|
d4ad00c509 | ||
|
|
2fdef6883c | ||
|
|
598034b1d3 | ||
|
|
4ad7c7a96a | ||
|
|
14f66ca105 | ||
|
|
831dbeb975 | ||
|
|
aecf17aae4 | ||
|
|
037d3dad01 | ||
|
|
e80c2e55b5 | ||
|
|
581b6abdd8 | ||
|
|
4c43c7446a | ||
|
|
0f73094afd | ||
|
|
53f8e6c8cb | ||
|
|
5c9fc8723c | ||
|
|
8995004feb | ||
|
|
4fa5991889 | ||
|
|
9925c10d03 | ||
|
|
7fc68028ea | ||
|
|
6136ec4909 | ||
|
|
d8a366b3d4 | ||
|
|
03e5814a57 | ||
|
|
9421cda8cf | ||
|
|
25490642f9 | ||
|
|
b01892a128 | ||
|
|
7baeed0cf6 | ||
|
|
f1a9e2f69a | ||
|
|
fdfc00f3d7 | ||
|
|
ee33e4bfde | ||
|
|
39754807cc | ||
|
|
752251b1b6 | ||
|
|
95c613b098 | ||
|
|
a970c5c7f6 | ||
|
|
78f6a55e3c | ||
|
|
563b90c773 | ||
|
|
bf996a2521 | ||
|
|
f04ce740a1 | ||
|
|
4a455cd883 | ||
|
|
6ae0d2ac72 | ||
|
|
4b156e5666 | ||
|
|
ede6d92ab6 | ||
|
|
5a4e664597 | ||
|
|
40d2739a5f | ||
|
|
52f632e4c2 | ||
|
|
5ac2316f79 | ||
|
|
22ff94a5d6 | ||
|
|
7d2fa3cf5c | ||
|
|
b8f0213d16 | ||
|
|
d4e0da9e21 | ||
|
|
95c75bdd5b | ||
|
|
36d57f1bba | ||
|
|
95d4e7705f | ||
|
|
87babb1a3b | ||
|
|
2966588338 | ||
|
|
7e07d3b5c4 | ||
|
|
1b9d866977 | ||
|
|
0fc4a042c5 | ||
|
|
b865ca9357 | ||
|
|
f817d76b73 | ||
|
|
c695f577ff | ||
|
|
4cb82bf40b | ||
|
|
449e7f8501 | ||
|
|
7b0d350fb7 | ||
|
|
ed2cc613e1 | ||
|
|
79f193f0d8 | ||
|
|
149f24255b | ||
|
|
c1a82bfe7f | ||
|
|
5083578c1e | ||
|
|
5f11dc4580 | ||
|
|
27489f188e | ||
|
|
97fffc3b43 | ||
|
|
a30193b8ef | ||
|
|
cf063dc19f | ||
|
|
5db829b7be | ||
|
|
421f8b1986 | ||
|
|
a140eedc6e | ||
|
|
2933568658 | ||
|
|
29faeaf7fa | ||
|
|
95ba532ceb | ||
|
|
352ecb9d6d | ||
|
|
3f73049a3d | ||
|
|
50ad0c6cd0 | ||
|
|
78f95bbb06 | ||
|
|
6134056776 | ||
|
|
b79c6350ec | ||
|
|
8148041ddc | ||
|
|
4265624c0e | ||
|
|
ed4179de8f | ||
|
|
e407a1b2dd | ||
|
|
e851101a93 | ||
|
|
d43e1ef4cc | ||
|
|
f699019994 | ||
|
|
fe90b0339d | ||
|
|
3b5ba802f6 | ||
|
|
b0c6e7f564 | ||
|
|
658a23724e | ||
|
|
f859183628 | ||
|
|
53dc14f413 | ||
|
|
f2523095ff | ||
|
|
ef5c04b67f | ||
|
|
10ed6f709e | ||
|
|
c22de3bc7c |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: ha-frontend-components
|
||||
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.
|
||||
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.
|
||||
---
|
||||
|
||||
# HA Frontend Components
|
||||
@@ -91,6 +91,36 @@ 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,6 +10,7 @@ Use this skill when editing CSS, layout, visual hierarchy, theme integration, re
|
||||
## Theme And Layout Basics
|
||||
|
||||
- Use Home Assistant CSS custom properties instead of hardcoded colors.
|
||||
- CSS in the static HTML templates under `src/html/` must give custom properties a fallback value (for example `var(--ha-space-4, 16px)`), because the global tokens only exist once the JS-appended global stylesheet from `append-ha-style` has loaded.
|
||||
- Use `--ha-space-*` spacing tokens instead of hardcoded spacing where possible.
|
||||
- Keep components mobile-first and enhance for desktop.
|
||||
- Keep layouts RTL-safe. Prefer logical properties when they fit.
|
||||
|
||||
@@ -12,3 +12,7 @@
|
||||
|
||||
demo/public/api/camera_proxy_stream/* binary
|
||||
demo/public/api/media_player_proxy/* binary
|
||||
|
||||
# Generated by `gh actions-lock`. Highlight it as YAML and don't auto-merge it:
|
||||
# on a conflict, restore it from the base branch and run `gh actions-lock`.
|
||||
.github/workflows/actions.lock linguist-language=YAML -merge
|
||||
@@ -51,8 +51,8 @@
|
||||
|
||||
- This PR fixes or closes issue: fixes #
|
||||
- This PR is related to issue or discussion:
|
||||
- Link to documentation pull request:
|
||||
- Link to developer documentation pull request:
|
||||
- Link to [documentation][docs-repository] pull request:
|
||||
- Link to [contributors documentation][contributors-repository] pull request:
|
||||
- Link to backend pull request:
|
||||
|
||||
## Checklist
|
||||
@@ -63,7 +63,8 @@
|
||||
for before merging your code.
|
||||
|
||||
AI tools are welcome, but contributors are responsible for *fully*
|
||||
understanding the code before submitting a PR.
|
||||
understanding the code before submitting a PR. Please follow our AI policy:
|
||||
https://contributors.home-assistant.io/start/ai-policy/
|
||||
-->
|
||||
|
||||
- [ ] I understand the code I am submitting and can explain how it works.
|
||||
@@ -105,4 +106,5 @@ 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
|
||||
@@ -0,0 +1,6 @@
|
||||
paths:
|
||||
.github/workflows/**/*.{yml,yaml}:
|
||||
ignore:
|
||||
# actionlint doesn't support `$/` self-repository actions yet, remove once it does:
|
||||
# https://github.com/rhysd/actionlint/issues/711
|
||||
- 'specifying action "\$/[^"]+" in invalid format because ref is missing'
|
||||
@@ -24,7 +24,7 @@ runs:
|
||||
|
||||
- name: Setup Node and install
|
||||
if: steps.dependencies.outputs.cache-hit != 'true'
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
cache: false
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Verify actions lockfile
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/workflows/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Verify actions lockfile
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_ACTIONS_LOCK_VERSION: v0.4.1
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install gh-actions-lock
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh extension install github/gh-actions-lock --pin "${GH_ACTIONS_LOCK_VERSION}"
|
||||
- name: Verify lockfile
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh actions-lock --verify
|
||||
@@ -0,0 +1,190 @@
|
||||
# This file is machine-generated by `gh actions-lock`.
|
||||
# Do not edit by hand; run `gh actions-lock` to update.
|
||||
# Docs: https://gh.io/actions-lockfile
|
||||
version: 'v0.0.3'
|
||||
workflows:
|
||||
'.github/workflows/actionlint.yaml':
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
'.github/workflows/actions-lock.yaml':
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
'.github/workflows/cast_deployment.yaml':
|
||||
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
'.github/workflows/ci.yaml':
|
||||
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
|
||||
'.github/workflows/codeql-analysis.yaml':
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'github/codeql-action@9b3254085494b55a0f7c4c2b3d2d52cc712ce370'
|
||||
'.github/workflows/copilot-setup-steps.yml':
|
||||
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
'.github/workflows/demo_deployment.yaml':
|
||||
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
'.github/workflows/dependency-review.yaml':
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294'
|
||||
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
|
||||
'.github/workflows/design_deployment.yaml':
|
||||
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
'.github/workflows/design_preview.yaml':
|
||||
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
'.github/workflows/e2e.yaml':
|
||||
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333'
|
||||
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
|
||||
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
|
||||
'.github/workflows/labeler.yaml':
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
|
||||
- 'actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13'
|
||||
'.github/workflows/lock.yaml':
|
||||
- 'dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c'
|
||||
'.github/workflows/nightly.yaml':
|
||||
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
- 'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
|
||||
- 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9'
|
||||
'.github/workflows/pull-request-standards.yaml':
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
|
||||
'.github/workflows/relative-ci.yaml':
|
||||
- 'relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60'
|
||||
'.github/workflows/release-drafter.yaml':
|
||||
- 'release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0'
|
||||
'.github/workflows/release.yaml':
|
||||
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
- 'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
|
||||
- 'home-assistant/actions@86d4564712fa02543c808e3a693dd04f9d3f8361'
|
||||
- 'home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716'
|
||||
- 'pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086'
|
||||
'.github/workflows/restrict-task-creation.yaml':
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3'
|
||||
'.github/workflows/stale.yaml':
|
||||
- 'actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93'
|
||||
'.github/workflows/sync-device-classes.yaml':
|
||||
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
- 'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1'
|
||||
'.github/workflows/sync-sensor-entity-constants.yaml':
|
||||
- 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
- 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
- 'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1'
|
||||
'.github/workflows/translations.yaml':
|
||||
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
dependencies:
|
||||
'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9':
|
||||
ref: 'v6.1.0'
|
||||
commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
|
||||
owner_id: 44036562
|
||||
repo_id: 215566462
|
||||
'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1':
|
||||
ref: 'v7.0.1'
|
||||
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
owner_id: 44036562
|
||||
repo_id: 197814629
|
||||
'actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294':
|
||||
ref: 'v5.0.0'
|
||||
commit: 'sha1-a1d282b36b6f3519aa1f3fc636f609c47dddb294'
|
||||
owner_id: 44036562
|
||||
repo_id: 476372928
|
||||
'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333':
|
||||
ref: 'v8.0.2'
|
||||
commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333'
|
||||
owner_id: 44036562
|
||||
repo_id: 192626254
|
||||
'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3':
|
||||
ref: 'v9.0.0'
|
||||
commit: 'sha1-3a2844b7e9c422d3c10d287c895573f7108da1b3'
|
||||
owner_id: 44036562
|
||||
repo_id: 205262760
|
||||
'actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13':
|
||||
ref: 'v7.0.0'
|
||||
commit: 'sha1-bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13'
|
||||
owner_id: 44036562
|
||||
repo_id: 177139928
|
||||
'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1':
|
||||
ref: 'v7.1.0'
|
||||
commit: 'sha1-949feb2413d6458794dcd2491c4babbbce0c15c1'
|
||||
owner_id: 44036562
|
||||
repo_id: 189476904
|
||||
'actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0':
|
||||
ref: 'v7.1.0'
|
||||
commit: 'sha1-6d4fd22b20465ccc0f7354476588e8df7c9ca0e0'
|
||||
owner_id: 44036562
|
||||
repo_id: 192625525
|
||||
'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405':
|
||||
ref: 'v6.2.0'
|
||||
commit: 'sha1-a309ff8b426b58ec0e2a45f0f869d46889d02405'
|
||||
owner_id: 44036562
|
||||
repo_id: 192625525
|
||||
'actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93':
|
||||
ref: 'v11.0.0'
|
||||
commit: 'sha1-4391f3da665fdf50b6810c1a66712fb9ba21aa93'
|
||||
owner_id: 44036562
|
||||
repo_id: 200437066
|
||||
'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9':
|
||||
ref: 'v7.0.2'
|
||||
commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9'
|
||||
owner_id: 44036562
|
||||
repo_id: 192625955
|
||||
'dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c':
|
||||
ref: 'v6.0.2'
|
||||
commit: 'sha1-89ae32b08ed1a541efecbab17912962a5e38981c'
|
||||
owner_id: 7513068
|
||||
repo_id: 113918130
|
||||
'github/codeql-action@9b3254085494b55a0f7c4c2b3d2d52cc712ce370':
|
||||
ref: 'v4.39.1'
|
||||
commit: 'sha1-9b3254085494b55a0f7c4c2b3d2d52cc712ce370'
|
||||
owner_id: 9919
|
||||
repo_id: 259445878
|
||||
'home-assistant/actions@86d4564712fa02543c808e3a693dd04f9d3f8361':
|
||||
ref: 'master'
|
||||
commit: 'sha1-86d4564712fa02543c808e3a693dd04f9d3f8361'
|
||||
owner_id: 13844975
|
||||
repo_id: 173703505
|
||||
'home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716':
|
||||
ref: '2026.07.0'
|
||||
commit: 'sha1-9e17ab1ed5c4c79d8b61e29fa63de25ca2710716'
|
||||
owner_id: 13844975
|
||||
repo_id: 184234812
|
||||
'peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1':
|
||||
ref: 'v8.1.1'
|
||||
commit: 'sha1-5f6978faf089d4d20b00c7766989d076bb2fc7f1'
|
||||
owner_id: 18365890
|
||||
repo_id: 197176365
|
||||
'pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086':
|
||||
ref: 'v1.14.3'
|
||||
commit: 'sha1-7245906fb61fa33a13554f9d5fb856fe61758086'
|
||||
owner_id: 647025
|
||||
repo_id: 178055147
|
||||
uses:
|
||||
- 'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405'
|
||||
'relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60':
|
||||
ref: 'v3.2.4'
|
||||
commit: 'sha1-711f3a8fa1b928e6afde280011110c9b0a224a60'
|
||||
owner_id: 43522883
|
||||
repo_id: 398376354
|
||||
'release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0':
|
||||
ref: 'v7.10.0'
|
||||
commit: 'sha1-978bb0b30c5ee8cdd332ced253758157150d43f0'
|
||||
owner_id: 58292376
|
||||
repo_id: 139022359
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
deploy_dev:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy Development
|
||||
if: github.repository == 'home-assistant/frontend' && github.event_name != 'push'
|
||||
if: github.repository == 'home-assistant/frontend' && github.ref_name != 'master'
|
||||
environment:
|
||||
name: Cast Development
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
@@ -30,17 +30,17 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
|
||||
- name: Build Cast
|
||||
uses: ./.github/actions/build
|
||||
uses: $/.github/actions/build
|
||||
with:
|
||||
target: build-cast
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
uses: $/.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: cast/dist
|
||||
alias: dev
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
deploy_master:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy Production
|
||||
if: github.repository == 'home-assistant/frontend' && github.event_name == 'push'
|
||||
if: github.repository == 'home-assistant/frontend' && github.ref_name == 'master'
|
||||
environment:
|
||||
name: Cast Production
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
@@ -62,17 +62,17 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
|
||||
- name: Build Cast
|
||||
uses: ./.github/actions/build
|
||||
uses: $/.github/actions/build
|
||||
with:
|
||||
target: build-cast
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
uses: $/.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: cast/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Prepare dependencies
|
||||
uses: ./.github/actions/prepare-dependencies
|
||||
uses: $/.github/actions/prepare-dependencies
|
||||
|
||||
lint:
|
||||
name: Lint and check format
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
- name: Check for duplicate dependencies
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
- name: Build resources
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
# Read-only reuse of the rspack cache written by the nightly (see
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
restore-keys: |
|
||||
rspack-cache-${{ runner.os }}-
|
||||
- name: Build Application
|
||||
uses: ./.github/actions/build
|
||||
uses: $/.github/actions/build
|
||||
with:
|
||||
target: build-app
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
@@ -22,10 +22,45 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
# These steps mirror .github/actions/setup with node-modules-cache enabled.
|
||||
# Copilot runs this workflow in a context that can't resolve `$/`
|
||||
# self-repository actions, so the steps are inlined here.
|
||||
- name: Restore complete dependency tree
|
||||
id: dependency-cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
node-modules-cache: true
|
||||
path: |
|
||||
node_modules
|
||||
~/.local/share/pnpm/store
|
||||
key: >-
|
||||
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'patches/**') }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-store
|
||||
if: steps.dependency-cache.outputs.cache-hit != 'true'
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache pnpm store
|
||||
if: steps.pnpm-store.outputs.path
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: pnpm-store-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.dependency-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Skip fetching nightly translations
|
||||
run: echo "SKIP_FETCH_NIGHTLY_TRANSLATIONS=1" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
deploy_dev:
|
||||
runs-on: ubuntu-latest
|
||||
name: Demo Development
|
||||
if: github.repository == 'home-assistant/frontend' && (github.event_name != 'push' || github.ref_name != 'master')
|
||||
if: github.repository == 'home-assistant/frontend' && github.ref_name != 'master'
|
||||
environment:
|
||||
name: Demo Development
|
||||
url: ${{ steps.deploy.outputs.unique_deploy_url }}
|
||||
@@ -31,17 +31,17 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
|
||||
- name: Build Demo
|
||||
uses: ./.github/actions/build
|
||||
uses: $/.github/actions/build
|
||||
with:
|
||||
target: build-demo
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
uses: $/.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: demo/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
deploy_master:
|
||||
runs-on: ubuntu-latest
|
||||
name: Demo Production
|
||||
if: github.repository == 'home-assistant/frontend' && github.event_name == 'push' && github.ref_name == 'master'
|
||||
if: github.repository == 'home-assistant/frontend' && github.ref_name == 'master'
|
||||
environment:
|
||||
name: Demo Production
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
@@ -62,17 +62,17 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
|
||||
- name: Build Demo
|
||||
uses: ./.github/actions/build
|
||||
uses: $/.github/actions/build
|
||||
with:
|
||||
target: build-demo
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
uses: $/.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: demo/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
|
||||
@@ -25,17 +25,17 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
|
||||
- name: Build Gallery
|
||||
uses: ./.github/actions/build
|
||||
uses: $/.github/actions/build
|
||||
with:
|
||||
target: build-gallery
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
uses: $/.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: gallery/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
|
||||
@@ -29,17 +29,17 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
|
||||
- name: Build Gallery
|
||||
uses: ./.github/actions/build
|
||||
uses: $/.github/actions/build
|
||||
with:
|
||||
target: build-gallery
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy preview to Netlify
|
||||
id: deploy
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
uses: $/.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: gallery/dist
|
||||
alias: deploy-preview-${{ github.event.number }}
|
||||
|
||||
+16
-16
@@ -32,13 +32,13 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Prepare dependencies
|
||||
uses: ./.github/actions/prepare-dependencies
|
||||
uses: $/.github/actions/prepare-dependencies
|
||||
|
||||
prepare-container-dependencies:
|
||||
name: Prepare container dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
|
||||
options: --user 1001
|
||||
defaults:
|
||||
run:
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Prepare dependencies
|
||||
uses: ./.github/actions/prepare-dependencies
|
||||
uses: $/.github/actions/prepare-dependencies
|
||||
with:
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
@@ -66,12 +66,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build demo
|
||||
uses: ./.github/actions/build
|
||||
uses: $/.github/actions/build
|
||||
with:
|
||||
target: build-demo-e2e
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -97,12 +97,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build e2e test app
|
||||
uses: ./.github/actions/build
|
||||
uses: $/.github/actions/build
|
||||
with:
|
||||
target: build-e2e-test-app-e2e
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -128,12 +128,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build gallery
|
||||
uses: ./.github/actions/build
|
||||
uses: $/.github/actions/build
|
||||
with:
|
||||
target: build-gallery
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -176,7 +176,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -230,7 +230,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
@@ -261,7 +261,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -284,7 +284,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
@@ -326,7 +326,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
|
||||
@@ -26,12 +26,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.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
|
||||
|
||||
|
||||
@@ -31,15 +31,15 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@cd6f3635a740c84d8a14f6121a90068b8ece6bf6 # master
|
||||
uses: home-assistant/actions/helpers/verify-version@86d4564712fa02543c808e3a693dd04f9d3f8361 # master
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
uses: $/.github/actions/setup
|
||||
with:
|
||||
immutable: false
|
||||
cache: false
|
||||
@@ -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@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, shortcuts, tooltips, panels, and Lovelace cards.
|
||||
- `ha-frontend-components`: dialogs, forms, alerts, loading states, 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.
|
||||
|
||||
@@ -284,6 +284,7 @@ module.exports.config = {
|
||||
name: "demo" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
main: path.resolve(paths.demo_dir, "src/entrypoint.ts"),
|
||||
embed: path.resolve(paths.demo_dir, "src/embed/entrypoint.ts"),
|
||||
...module.exports.mapWorkerEntry(),
|
||||
},
|
||||
outputPath: outputPath(paths.demo_output_root, latestBuild),
|
||||
@@ -291,6 +292,8 @@ module.exports.config = {
|
||||
defineOverlay: {
|
||||
__VERSION__: JSON.stringify(`DEMO-${env.version()}`),
|
||||
__DEMO__: true,
|
||||
// The embed sets the global to load static files from its own server.
|
||||
__STATIC_PATH__: '(globalThis.__HA_STATIC_PATH__ || "/static/")',
|
||||
},
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
|
||||
@@ -232,7 +232,11 @@ gulp.task(
|
||||
)
|
||||
);
|
||||
|
||||
const DEMO_PAGE_ENTRIES = { "index.html": ["main"] };
|
||||
const DEMO_PAGE_ENTRIES = {
|
||||
"index.html": ["main"],
|
||||
"components.html": ["embed"],
|
||||
"embed.js": ["embed"],
|
||||
};
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-demo-dev",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* global module, require */
|
||||
// rspack/webpack loader that minifies the HTML, SVG, and CSS inside lit
|
||||
// tagged template literals using `minify-literals` (html-minifier-next +
|
||||
// lightningcss). Replaces the unmaintained babel-plugin-template-html-minifier.
|
||||
@@ -36,6 +35,14 @@ const initLoader = (browserslistEnv) => {
|
||||
return {
|
||||
minifyHTMLLiterals: minifyModule.minifyHTMLLiterals,
|
||||
lightningcssTargets,
|
||||
// With the legacy floor at Chrome 67, lightningcss rewrites rgba()
|
||||
// to 8-digit hex (#rrggbbaa). Some kiosk webviews served the legacy
|
||||
// build (e.g. Shelly Wall Display) can't parse that, so any
|
||||
// var(--divider-color) border falls back to currentColor. Always
|
||||
// compile hex alpha colors to rgba() in legacy builds.
|
||||
lightningcssInclude: browserslistEnv.startsWith("legacy")
|
||||
? lightningcssModule.Features.HexAlphaColors
|
||||
: 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -67,12 +74,13 @@ module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
|
||||
const { browserslistEnv } = this.getOptions();
|
||||
|
||||
initLoader(browserslistEnv)
|
||||
.then(({ minifyHTMLLiterals, lightningcssTargets }) =>
|
||||
.then(({ minifyHTMLLiterals, lightningcssTargets, lightningcssInclude }) =>
|
||||
minifyHTMLLiterals(source, {
|
||||
fileName: this.resourcePath,
|
||||
html: htmlOptions,
|
||||
css: {
|
||||
targets: lightningcssTargets,
|
||||
include: lightningcssInclude,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
@@ -214,16 +214,18 @@ const createRspackConfig = ({
|
||||
},
|
||||
},
|
||||
{
|
||||
// MapLibre's worker loads ESM plugins through `import(url)`, and the
|
||||
// page side builds a blob import from `new URL(url, import.meta.url)`
|
||||
// for a cross-origin worker. Neither path is taken here: the RTL
|
||||
// plugin is UMD and the worker is served from the page's origin.
|
||||
// Rspack's stubs for these fully dynamic requests are exactly what
|
||||
// should remain - rejecting, and free of syntax the legacy floor
|
||||
// cannot parse - so the warnings about them are noise.
|
||||
// MapLibre's worker loads ESM plugins through `import(url)`, which
|
||||
// is never taken here: the RTL plugin is UMD. Rspack's stub for that
|
||||
// fully dynamic request is exactly what should remain - rejecting,
|
||||
// and free of syntax the legacy floor cannot parse - so the warning
|
||||
// about it is noise. The page side builds a blob import from
|
||||
// `new URL(url, import.meta.url)` for a worker on another origin,
|
||||
// as in the demo embed. That is a URL, not an asset to bundle, and
|
||||
// the worker URL is absolute, so leave it as it is.
|
||||
test: /[\\/]maplibre-gl[\\/]dist[\\/]maplibre-gl(?:-worker)?\.mjs$/,
|
||||
parser: {
|
||||
exprContextCritical: false,
|
||||
url: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -242,11 +244,22 @@ const createRspackConfig = ({
|
||||
path: paths.root_dir,
|
||||
env: "legacy",
|
||||
}),
|
||||
// Keep rgba() instead of #rrggbbaa, see
|
||||
// minify-template-literals-loader.cjs
|
||||
include: { hexAlphaColors: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
// The demo embed reads its own URL at runtime to find its files.
|
||||
// Keep import.meta.url instead of replacing it with the source path.
|
||||
// Development builds wrap modules in eval(), which rejects import.meta.
|
||||
latestBuild &&
|
||||
isProdBuild && {
|
||||
test: path.resolve(paths.demo_dir, "src/embed/public-path.ts"),
|
||||
parser: { importMeta: false },
|
||||
},
|
||||
].filter(Boolean),
|
||||
},
|
||||
optimization: {
|
||||
minimizer: [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
|
||||
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
|
||||
Content-Security-Policy: form-action https:
|
||||
Feature-Policy: vibrate 'none'; geolocation 'none'; midi 'none'; microphone 'none'; camera 'none'; magnetometer 'none'; gyroscope 'none'; speaker 'none'; vibrate 'none'; payment 'none'
|
||||
Referrer-Policy: no-referrer-when-downgrade
|
||||
@@ -8,13 +8,13 @@
|
||||
X-XSS-Protection: 1; mode=block
|
||||
|
||||
/images/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/manifest.json
|
||||
Cache-Control: public, max-age: 3600, s-maxage=3600
|
||||
Cache-Control: public, max-age=3600, s-maxage=3600
|
||||
|
||||
/frontend_es5/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/frontend_latest/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"background_color": "#FFFFFF",
|
||||
"description": "Show Home Assistant on your Chromecast or Google Assistant devices with a screen.",
|
||||
"description": "Show Home Assistant on your Chromecast or Google Home devices with a screen.",
|
||||
"dir": "ltr",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta property="og:type" content="website" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Show Home Assistant on your Chromecast or Google Assistant devices with a screen."
|
||||
content="Show Home Assistant on your Chromecast or Google Home devices with a screen."
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
@@ -16,7 +16,7 @@
|
||||
<meta name="twitter:title" content="Home Assistant Cast" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Show Home Assistant on your Chromecast or Google Assistant devices with a screen."
|
||||
content="Show Home Assistant on your Chromecast or Google Home devices with a screen."
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<p>
|
||||
Home Assistant Cast allows you to show your Home Assistant data on a
|
||||
Chromecast device and allows you to interact with Home Assistant on
|
||||
Google Assistant devices with a screen.
|
||||
Google Home devices with a screen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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 } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { CastManager } from "../../../../src/cast/cast_manager";
|
||||
import {
|
||||
@@ -14,12 +13,14 @@ import {
|
||||
enableWrite,
|
||||
saveTokens,
|
||||
} from "../../../../src/common/auth/token_storage";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
|
||||
import { atLeastVersion } from "../../../../src/common/config/version";
|
||||
import "../../../../src/components/ha-button";
|
||||
import "../../../../src/components/ha-icon";
|
||||
import "../../../../src/components/ha-list";
|
||||
import "../../../../src/components/ha-list-item";
|
||||
import "../../../../src/components/ha-svg-icon";
|
||||
import "../../../../src/components/item/ha-list-item-option";
|
||||
import type { HaListItemOption } from "../../../../src/components/item/ha-list-item-option";
|
||||
import "../../../../src/components/list/ha-list-selectable";
|
||||
import {
|
||||
getLegacyLovelaceCollection,
|
||||
getLovelaceCollection,
|
||||
@@ -53,6 +54,9 @@ class HcCast extends LitElement {
|
||||
`
|
||||
: undefined;
|
||||
|
||||
const views = this.lovelaceViews ?? [{ title: "Home" }];
|
||||
const hasIcons = views.some((view) => view.icon);
|
||||
|
||||
return html`
|
||||
<hc-layout .auth=${this.auth} .connection=${this.connection}>
|
||||
${
|
||||
@@ -93,45 +97,41 @@ class HcCast extends LitElement {
|
||||
</p>
|
||||
`
|
||||
: html`
|
||||
<div class="section-header">PICK A VIEW</div>
|
||||
<ha-list @action=${this._handlePickView} activatable>
|
||||
${(
|
||||
this.lovelaceViews ?? [
|
||||
{
|
||||
title: "Home",
|
||||
},
|
||||
]
|
||||
).map(
|
||||
<div class="section-header" id="pick-view-header">
|
||||
PICK A VIEW
|
||||
</div>
|
||||
<ha-list-selectable
|
||||
controlled
|
||||
aria-labelledby="pick-view-header"
|
||||
>
|
||||
${views.map(
|
||||
(view, idx) => html`
|
||||
<ha-list-item
|
||||
graphic="avatar"
|
||||
.activated=${
|
||||
this.castManager.status?.lovelacePath ===
|
||||
(view.path ?? idx)
|
||||
}
|
||||
<ha-list-item-option
|
||||
data-index=${idx}
|
||||
@click=${this._handlePickView}
|
||||
.headline=${view.title || view.path || "Unnamed view"}
|
||||
.selected=${
|
||||
this.castManager.status?.lovelacePath ===
|
||||
(view.path ?? idx)
|
||||
}
|
||||
>
|
||||
${view.title || view.path || "Unnamed view"}
|
||||
${
|
||||
view.icon
|
||||
? html`
|
||||
<ha-icon
|
||||
!hasIcons
|
||||
? nothing
|
||||
: view.icon
|
||||
? html`<ha-icon
|
||||
slot="start"
|
||||
.icon=${view.icon}
|
||||
slot="graphic"
|
||||
></ha-icon>
|
||||
`
|
||||
: html`<ha-svg-icon
|
||||
slot="item-icon"
|
||||
.path=${mdiViewDashboard}
|
||||
></ha-svg-icon>`
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiViewDashboard}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
</ha-list-item>
|
||||
</ha-list-item-option>
|
||||
`
|
||||
)}</ha-list
|
||||
>
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -194,14 +194,6 @@ class HcCast extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
this.toggleAttribute(
|
||||
"hide-icons",
|
||||
this.lovelaceViews ? !this.lovelaceViews.some((view) => view.icon) : true
|
||||
);
|
||||
}
|
||||
|
||||
private async _handleSkipSaveTokens() {
|
||||
this.askWrite = false;
|
||||
}
|
||||
@@ -215,8 +207,13 @@ class HcCast extends LitElement {
|
||||
this.castManager.requestSession();
|
||||
}
|
||||
|
||||
private async _handlePickView(ev: CustomEvent<ActionDetail>) {
|
||||
const path = this.lovelaceViews?.[ev.detail.index]?.path ?? ev.detail.index;
|
||||
// Listens to clicks instead of `ha-list-item-selected`, which does not fire
|
||||
// for the selected view, so the current view can still be cast again.
|
||||
private async _handlePickView(
|
||||
ev: HASSDomCurrentTargetEvent<HaListItemOption>
|
||||
) {
|
||||
const index = Number(ev.currentTarget.dataset.index);
|
||||
const path = this.lovelaceViews?.[index]?.path ?? index;
|
||||
await ensureConnectedCastSession(this.castManager!, this.auth!);
|
||||
castSendShowLovelaceView(this.castManager, this.auth.data.hassUrl, path);
|
||||
}
|
||||
@@ -271,16 +268,11 @@ class HcCast extends LitElement {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
ha-list-item ha-icon,
|
||||
ha-list-item ha-svg-icon {
|
||||
padding: 12px;
|
||||
ha-list-item-option ha-icon,
|
||||
ha-list-item-option ha-svg-icon {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
:host([hide-icons]) ha-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ const translateErr = (err) =>
|
||||
const INTRO = html`
|
||||
<p>
|
||||
Home Assistant Cast allows you to cast your Home Assistant installation to
|
||||
Chromecast video devices and to Google Assistant devices with a screen.
|
||||
Chromecast video devices and to Google Home devices with a screen.
|
||||
</p>
|
||||
<p>
|
||||
For more information, see the
|
||||
|
||||
@@ -6,7 +6,7 @@ const playerManager = castContext.getPlayerManager();
|
||||
|
||||
playerManager.setMessageInterceptor("LOAD", (loadRequestData) => {
|
||||
const media = loadRequestData.media;
|
||||
// Special handling if it came from Google Assistant
|
||||
// Special handling if it came from Google Home
|
||||
if (media.entity) {
|
||||
media.contentId = media.entity;
|
||||
media.streamType = "LIVE";
|
||||
|
||||
@@ -13,7 +13,7 @@ export const castDemoLovelace: () => LovelaceConfig = () => {
|
||||
{
|
||||
type: "markdown",
|
||||
title: "Home Assistant Cast",
|
||||
content: `With Home Assistant you can easily create interfaces (just like this one) which can be shown on Chromecast devices connected to TVs or Google Assistant devices with a screen.${
|
||||
content: `With Home Assistant you can easily create interfaces (just like this one) which can be shown on Chromecast devices connected to TVs or Google Home devices with a screen.${
|
||||
touchSupported
|
||||
? "\n\nYou are able to interact with this demo using the touch screen."
|
||||
: "\n\nOn a Google Nest Hub you are able to interact with Home Assistant Cast via the touch screen."
|
||||
|
||||
@@ -117,7 +117,7 @@ playerManager.setMessageInterceptor("LOAD", (loadRequestData) => {
|
||||
// We received a play media command, hide Lovelace and show media player
|
||||
showMediaPlayer();
|
||||
const media = loadRequestData.media;
|
||||
// Special handling if it came from Google Assistant
|
||||
// Special handling if it came from Google Home
|
||||
if (media.entity) {
|
||||
media.contentId = media.entity;
|
||||
media.streamType = "LIVE";
|
||||
|
||||
+12
-5
@@ -1,18 +1,25 @@
|
||||
/*
|
||||
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
|
||||
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
|
||||
Content-Security-Policy: form-action https:
|
||||
Referrer-Policy: no-referrer-when-downgrade
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 1; mode=block
|
||||
|
||||
/api/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/assets/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/frontend_es5/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/frontend_latest/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
Access-Control-Allow-Origin: *
|
||||
|
||||
/embed.js
|
||||
Access-Control-Allow-Origin: *
|
||||
|
||||
/static/*
|
||||
Access-Control-Allow-Origin: *
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createContext } from "@lit/context";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
|
||||
export const embedHassContext = createContext<HomeAssistant>("embedHass");
|
||||
@@ -0,0 +1,173 @@
|
||||
import { ContextEvent } from "@lit/context";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { consume } from "../../../src/common/decorators/consume";
|
||||
import { computeRTLDirection } from "../../../src/common/util/compute_rtl";
|
||||
import { handleAction } from "../../../src/panels/lovelace/common/handle-action";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { embedHassContext } from "./context";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
import { readScriptObject } from "./script-data";
|
||||
|
||||
const pageProvider = (): HTMLElement =>
|
||||
document.querySelector("ha-embed-provider") ??
|
||||
document.body.appendChild(document.createElement("ha-embed-provider"));
|
||||
|
||||
// Looks past shadow roots, so an element inside another component still
|
||||
// finds the provider around that component.
|
||||
const insideProvider = (element: Element): boolean => {
|
||||
let current: Element | null = element;
|
||||
|
||||
while (current) {
|
||||
if (current.closest("ha-embed-provider")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const root = current.getRootNode();
|
||||
current = root instanceof ShadowRoot ? root.host : null;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* The base of the embed elements that render dashboard content. The element
|
||||
* uses the provider with the ID in `provider`, else the closest provider
|
||||
* around it, else the first provider on the page. If the page has no
|
||||
* provider, the element adds an empty one.
|
||||
*/
|
||||
export abstract class EmbedElement<
|
||||
Config extends ScriptObject,
|
||||
> extends LitElement {
|
||||
/**
|
||||
* The configuration of the content, the same as in a dashboard. Also read
|
||||
* from a JSON or YAML script child, see `readScriptObject`.
|
||||
*/
|
||||
@property({ type: Object }) public config?: Config;
|
||||
|
||||
/** The ID of the `ha-embed-provider` to use. */
|
||||
@property() public provider?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: embedHassContext, subscribe: true })
|
||||
protected _hass?: HomeAssistant;
|
||||
|
||||
// A provider that is not an ancestor, which gets the context requests of the
|
||||
// element and its content. Context requests only reach ancestors by
|
||||
// themselves.
|
||||
private _linkedProvider?: HTMLElement;
|
||||
|
||||
private _scriptRead = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addEventListener("context-request", (ev) => {
|
||||
if (!this._linkedProvider) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.stopPropagation();
|
||||
this._linkedProvider.dispatchEvent(
|
||||
new ContextEvent(
|
||||
ev.context,
|
||||
ev.contextTarget,
|
||||
ev.callback,
|
||||
ev.subscribe
|
||||
)
|
||||
);
|
||||
});
|
||||
// Cards fire their tap, hold and double tap actions for the app to handle
|
||||
this.addEventListener("hass-action", (ev) => {
|
||||
if (this._hass) {
|
||||
// The element that fired the action, so that fire-dom-event reaches
|
||||
// listeners inside custom cards, as in action-mixin
|
||||
const [origin] = ev.composedPath();
|
||||
const node = origin instanceof HTMLElement ? origin : this;
|
||||
|
||||
handleAction(node, this._hass, ev.detail.config, ev.detail.action);
|
||||
}
|
||||
});
|
||||
// The page's address belongs to the page, so only a hash on it, such as
|
||||
// an anchor, navigates there. The browser does that itself, as the app's
|
||||
// navigate() doesn't scroll to the anchor.
|
||||
this.addEventListener("hass-navigate", (ev) => {
|
||||
const { path, replace } = ev.detail;
|
||||
|
||||
ev.preventDefault();
|
||||
|
||||
if (!path.startsWith("#")) {
|
||||
this.navigateTo(path, replace);
|
||||
} else if (replace) {
|
||||
window.location.replace(path);
|
||||
} else {
|
||||
window.location.hash = path;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
// Before super.connectedCallback(), which sends the context requests.
|
||||
this._linkedProvider = this.provider
|
||||
? (document.getElementById(this.provider) ?? undefined)
|
||||
: insideProvider(this)
|
||||
? undefined
|
||||
: pageProvider();
|
||||
super.connectedCallback();
|
||||
|
||||
// Only once, so that moving the element keeps a config set since
|
||||
if (this._scriptRead) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._scriptRead = true;
|
||||
|
||||
const data = readScriptObject(this);
|
||||
|
||||
if (data !== undefined) {
|
||||
this.setScriptData(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A navigate action of the content, other than to a hash on the page. It
|
||||
* leaves the page where it is, like actions that open a dialog. `replace`
|
||||
* is the action's `navigation_replace`.
|
||||
*/
|
||||
protected navigateTo(_path: string, _replace?: boolean) {
|
||||
// Nothing to navigate to, outside a dashboard
|
||||
}
|
||||
|
||||
/** Whether the data of a script child is a configuration of the content. */
|
||||
protected abstract isConfig(data: ScriptObject): data is Config;
|
||||
|
||||
/** Sets the data of a JSON or YAML script child, see `readScriptObject`. */
|
||||
protected setScriptData(data: ScriptObject) {
|
||||
if (this.isConfig(data)) {
|
||||
this.config = data;
|
||||
}
|
||||
}
|
||||
|
||||
/** The content, or `undefined` while there is nothing to render. */
|
||||
protected abstract renderContent(
|
||||
hass: HomeAssistant
|
||||
): TemplateResult | undefined;
|
||||
|
||||
// The content in the theme of the provider
|
||||
protected render() {
|
||||
const hass = this._hass;
|
||||
const content = hass && this.renderContent(hass);
|
||||
|
||||
if (!hass || !content) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<div
|
||||
class=${classMap({ theme: true, dark: hass.themes.darkMode })}
|
||||
dir=${computeRTLDirection(hass)}
|
||||
>
|
||||
${content}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import "./public-path";
|
||||
import "./workers";
|
||||
// The provider must be defined first. Custom elements upgrade in the order
|
||||
// they are defined, and cards need a provider to request their context from.
|
||||
import "./ha-embed-provider";
|
||||
import "./ha-embed-card";
|
||||
import "./ha-embed-section";
|
||||
import "./ha-embed-view";
|
||||
import "./ha-embed-dashboard";
|
||||
// For custom cards, which can't load them themselves. The built-in cards only
|
||||
// load ha-list-item-button with the map card. The full app loads ha-select
|
||||
// with the more info dialog.
|
||||
import "../../../src/components/item/ha-list-item-button";
|
||||
import "../../../src/components/ha-select";
|
||||
import { fontStyles } from "../../../src/resources/roboto";
|
||||
|
||||
// Font faces only work in the document, not in the shadow root of the card.
|
||||
// Defining Roboto does not change the page unless it already uses Roboto.
|
||||
document.head.append(
|
||||
Object.assign(document.createElement("style"), {
|
||||
textContent: fontStyles.cssText,
|
||||
})
|
||||
);
|
||||
|
||||
// Custom cards use the helpers to create built-in cards. The dashboard panel
|
||||
// sets the same global in the full app.
|
||||
(window as any).loadCardHelpers = () =>
|
||||
import("../../../src/panels/lovelace/custom-card-helpers");
|
||||
@@ -0,0 +1,99 @@
|
||||
import { css, html } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
|
||||
import { computeCardGridSize } from "../../../src/panels/lovelace/common/compute-card-grid-size";
|
||||
import "../../../src/panels/lovelace/cards/hui-card";
|
||||
import type { HuiCard } from "../../../src/panels/lovelace/cards/hui-card";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { EmbedElement } from "./embed-element";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
import { embedElementStyles } from "./theme-styles";
|
||||
|
||||
/**
|
||||
* Renders a Lovelace card with the backend of a provider, see `EmbedElement`.
|
||||
*
|
||||
* The card takes the columns and rows it would take in a section, so cards
|
||||
* in an element with `display: grid` and 12 columns lay out like a section.
|
||||
* It also sets `--column-size` and `--row-size` on itself.
|
||||
*/
|
||||
@customElement("ha-embed-card")
|
||||
export class HaEmbedCard extends EmbedElement<LovelaceCardConfig> {
|
||||
private _card = createRef<HuiCard>();
|
||||
|
||||
// A card names its type
|
||||
protected isConfig(data: ScriptObject): data is LovelaceCardConfig {
|
||||
return "type" in data && typeof data.type === "string";
|
||||
}
|
||||
|
||||
protected renderContent(hass: HomeAssistant) {
|
||||
if (!this.config) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return html`<hui-card
|
||||
${ref(this._card)}
|
||||
.hass=${hass}
|
||||
.config=${this.config}
|
||||
.layout=${"grid"}
|
||||
@card-updated=${this._updateSize}
|
||||
@card-visibility-changed=${this._updateSize}
|
||||
></hui-card>`;
|
||||
}
|
||||
|
||||
protected updated() {
|
||||
// The card element only exists once hui-card has updated
|
||||
this._card.value?.updateComplete.then(() => this._updateSize());
|
||||
}
|
||||
|
||||
// The size the card gets in a section, set on the host so that the page can
|
||||
// lay out cards in a grid of 12 columns, like a section
|
||||
private _updateSize = () => {
|
||||
const card = this._card.value;
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
const { rows, columns } = computeCardGridSize(card.getGridOptions());
|
||||
this.hidden = card.hidden;
|
||||
this.toggleAttribute("full-width", columns === "full");
|
||||
this.toggleAttribute("fit-rows", typeof rows === "number");
|
||||
this.style.setProperty(
|
||||
"--column-size",
|
||||
typeof columns === "number" ? String(columns) : null
|
||||
);
|
||||
this.style.setProperty(
|
||||
"--row-size",
|
||||
typeof rows === "number" ? String(rows) : null
|
||||
);
|
||||
};
|
||||
|
||||
static styles = [
|
||||
embedElementStyles,
|
||||
css`
|
||||
/* The sizes of a card in a section, see hui-grid-section */
|
||||
:host {
|
||||
--row-gap: var(--ha-section-grid-row-gap, 8px);
|
||||
--row-height: var(--ha-section-grid-row-height, 56px);
|
||||
display: block;
|
||||
grid-row: span var(--row-size, 1);
|
||||
grid-column: span min(var(--column-size, 12), 12);
|
||||
}
|
||||
:host([full-width]) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
:host([fit-rows]) .theme {
|
||||
height: calc(
|
||||
(var(--row-size, 1) * (var(--row-height) + var(--row-gap))) - var(
|
||||
--row-gap
|
||||
)
|
||||
);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-embed-card": HaEmbedCard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
|
||||
import type {
|
||||
LovelaceConfig,
|
||||
LovelaceRawConfig,
|
||||
} from "../../../src/data/lovelace/config/types";
|
||||
import "../../../src/panels/lovelace/hui-root";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { EmbedLovelaceElement, isDashboardConfig } from "./lovelace";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
|
||||
/**
|
||||
* Renders a dashboard, or a dashboard strategy, with its tabs, with the
|
||||
* backend of a provider, see `EmbedElement`. Each dashboard keeps its own
|
||||
* view in `view`, so switching tabs and following links to its views don't
|
||||
* change the page's URL. The dashboard scrolls inside the element, under its
|
||||
* toolbar.
|
||||
*/
|
||||
@customElement("ha-embed-dashboard")
|
||||
export class HaEmbedDashboard extends EmbedLovelaceElement<LovelaceRawConfig> {
|
||||
private _scroller = createRef<HTMLDivElement>();
|
||||
|
||||
// The route of hui-root, here without a page URL around it. A new
|
||||
// configuration gives a new route, as hui-root only looks up the view of
|
||||
// the path again when its route changes, and the views may have moved.
|
||||
private _route = memoizeOne(
|
||||
(view: string | undefined, _config: LovelaceConfig) => ({
|
||||
prefix: "",
|
||||
path: `/${view ?? ""}`,
|
||||
})
|
||||
);
|
||||
|
||||
protected get rawConfig() {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
protected isConfig(data: ScriptObject): data is LovelaceRawConfig {
|
||||
return isDashboardConfig(data);
|
||||
}
|
||||
|
||||
// The view shown, and the views before it, which the back button of a
|
||||
// subview without a `back_path` goes back through, as the browser's back
|
||||
// would in the app
|
||||
private _shownView?: string;
|
||||
|
||||
private _history: (string | undefined)[] = [];
|
||||
|
||||
// Going back, or a navigate action with `navigation_replace`, which
|
||||
// replaces the view shown rather than adding to the views before it
|
||||
private _skipHistory = false;
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
|
||||
if (this.view === this._shownView) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._shownView !== undefined) {
|
||||
if (!this._skipHistory) {
|
||||
this._history.push(this._shownView);
|
||||
}
|
||||
|
||||
this._scroller.value?.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
this._skipHistory = false;
|
||||
this._shownView = this.view;
|
||||
}
|
||||
|
||||
protected navigateTo(path: string, replace?: boolean) {
|
||||
const view = this.viewOfPath(path);
|
||||
|
||||
if (view === undefined || this.isSameView(view, this.view)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._skipHistory = Boolean(replace);
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
// The embedded attribute is in the template, so that hui-root has it when
|
||||
// it connects and skips the listeners on the page
|
||||
protected renderContent(hass: HomeAssistant) {
|
||||
if (!this._lovelace?.config.views.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return html`<div class="scroller" ${ref(this._scroller)}>
|
||||
<hui-root
|
||||
embedded
|
||||
no-edit
|
||||
.hass=${hass}
|
||||
.lovelace=${this._lovelace}
|
||||
.route=${this._route(this.view, this._lovelace.config)}
|
||||
.narrow=${this._narrow.value ?? false}
|
||||
@view-navigate=${this._viewNavigate}
|
||||
></hui-root>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// A tab, or the back button of a subview, which goes to its `back_path`
|
||||
// when that is one of the dashboard's views, else to the view before it
|
||||
private _viewNavigate(ev: HASSDomEvent<HASSDomEvents["view-navigate"]>) {
|
||||
const view = this.viewOfPath(ev.detail.path);
|
||||
|
||||
if (!ev.detail.back) {
|
||||
if (view !== undefined) {
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const target = view === undefined ? this._previousView() : view;
|
||||
|
||||
// Following a `back_path` goes back too, so the view it goes to leaves
|
||||
// the views before
|
||||
if (
|
||||
view !== undefined &&
|
||||
this.isSameView(this._history[this._history.length - 1], view)
|
||||
) {
|
||||
this._history.pop();
|
||||
}
|
||||
|
||||
if (!this.isSameView(target, this.view)) {
|
||||
this._skipHistory = true;
|
||||
this.view = target;
|
||||
}
|
||||
}
|
||||
|
||||
// The last view before that isn't the one shown, else the first view
|
||||
private _previousView(): string | undefined {
|
||||
while (this._history.length) {
|
||||
const previous = this._history.pop();
|
||||
|
||||
if (!this.isSameView(previous, this.view)) {
|
||||
return previous;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
EmbedLovelaceElement.styles,
|
||||
css`
|
||||
/* Contained, so that the toolbar and background of hui-root, which are
|
||||
fixed to the window in the app, are fixed to the element */
|
||||
:host {
|
||||
height: 600px;
|
||||
contain: layout paint;
|
||||
}
|
||||
.theme,
|
||||
.scroller {
|
||||
height: 100%;
|
||||
}
|
||||
.scroller {
|
||||
overflow: auto;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-embed-dashboard": HaEmbedDashboard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { ContextProvider, provide } from "@lit/context";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../src/common/dom/fire_event";
|
||||
import { listenMediaQuery } from "../../../src/common/dom/media_query";
|
||||
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
|
||||
import type { CalendarEventApiData } from "../../../src/data/calendar";
|
||||
import { reducedMotionContext } from "../../../src/data/context";
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
import type { EnergyInfo, EnergyPreferences } from "../../../src/data/energy";
|
||||
import { EMPTY_PREFERENCES } from "../../../src/data/energy";
|
||||
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
|
||||
import type { EntityInput } from "../../../src/fake_data/entities/types";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { provideHass } from "../../../src/fake_data/provide_hass";
|
||||
import type { DemoArea } from "../stubs/area_registry";
|
||||
import { toAreaRegistryEntry } from "../stubs/area_registry";
|
||||
import { mockAuth } from "../stubs/auth";
|
||||
import {
|
||||
FAMILY_EVENTS,
|
||||
mockCalendarEvents,
|
||||
WORK_EVENTS,
|
||||
} from "../stubs/calendar";
|
||||
import type { DemoDevice } from "../stubs/device_registry";
|
||||
import { toDeviceRegistryEntry } from "../stubs/device_registry";
|
||||
import { extEntry, newEntry } from "../stubs/entity_registry";
|
||||
import { mockHistory } from "../stubs/history";
|
||||
import { mockIcons } from "../stubs/icons";
|
||||
import { mockLogbook } from "../stubs/logbook";
|
||||
import { mockMediaPlayer } from "../stubs/media_player";
|
||||
import { mockRecorder } from "../stubs/recorder";
|
||||
import { mockSensor } from "../stubs/sensor";
|
||||
import { mockExtractFromTarget } from "../stubs/target";
|
||||
import { mockTemplate } from "../stubs/template";
|
||||
import { mockTodo } from "../stubs/todo";
|
||||
import { mockWeather } from "../stubs/weather";
|
||||
import { embedHassContext } from "./context";
|
||||
import { readScriptData } from "./script-data";
|
||||
|
||||
/** The data of a provider, as set by its script child. */
|
||||
interface EmbedProviderData {
|
||||
entities?: EntityInput[];
|
||||
areas?: DemoArea[];
|
||||
devices?: DemoDevice[];
|
||||
calendars?: Record<string, CalendarEventApiData[]>;
|
||||
energy?: Partial<EnergyPreferences>;
|
||||
}
|
||||
|
||||
const SAMPLE_CALENDAR_EVENTS = [FAMILY_EVENTS, WORK_EVENTS];
|
||||
|
||||
// "living_room" becomes "Living room"
|
||||
const areaName = (areaId: string) => {
|
||||
const name = areaId.replaceAll("_", " ");
|
||||
|
||||
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Holds a mocked Home Assistant backend for all embed elements inside it.
|
||||
* Elements in the same provider share their entities.
|
||||
*/
|
||||
@customElement("ha-embed-provider")
|
||||
export class HaEmbedProvider extends LitElement {
|
||||
@provide({ context: embedHassContext })
|
||||
@property({ attribute: false })
|
||||
public hass!: MockHomeAssistant;
|
||||
|
||||
/**
|
||||
* All entities of the backend. Each change replaces all entities.
|
||||
* Also read from a JSON or YAML script child, see `readScriptData`. The
|
||||
* script holds either the list of entities or an object with `entities`,
|
||||
* `areas`, `devices`, `calendars` and `energy`.
|
||||
*/
|
||||
@property({ type: Array }) public entities: EntityInput[] = [];
|
||||
|
||||
/**
|
||||
* All areas. Without them, an area for each `area_id` of the entities and
|
||||
* devices.
|
||||
*/
|
||||
@property({ attribute: false }) public areas?: DemoArea[];
|
||||
|
||||
/** All devices, which entities reference with their `device_id`. */
|
||||
@property({ attribute: false }) public devices?: DemoDevice[];
|
||||
|
||||
/**
|
||||
* Events of calendar entities by entity ID. Calendars without events here
|
||||
* get sample events around today.
|
||||
*/
|
||||
@property({ attribute: false }) public calendars?: Record<
|
||||
string,
|
||||
CalendarEventApiData[]
|
||||
>;
|
||||
|
||||
/** Energy preferences. Without them, energy is not set up. */
|
||||
@property({ attribute: false }) public energy?: Partial<EnergyPreferences>;
|
||||
|
||||
/** Light or dark mode of the cards. Without it, the browser setting. */
|
||||
@property({ attribute: "color-scheme" }) public colorScheme?:
|
||||
"light" | "dark";
|
||||
|
||||
private _reducedMotion = new ContextProvider(this, {
|
||||
context: reducedMotionContext,
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
private _prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
|
||||
private _unsubMediaQueries: (() => void)[] = [];
|
||||
|
||||
private _scriptRead = false;
|
||||
|
||||
private _applyColorScheme() {
|
||||
const dark = this.colorScheme
|
||||
? this.colorScheme === "dark"
|
||||
: this._prefersDark;
|
||||
if (this.hass.themes.darkMode !== dark) {
|
||||
this.hass.updateHass({
|
||||
themes: { ...this.hass.themes, darkMode: dark },
|
||||
selectedTheme: { ...this.hass.selectedTheme!, dark },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Set up in the constructor so that the contexts exist before the
|
||||
// child cards connect and request them.
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null);
|
||||
hass.updateTranslations("lovelace");
|
||||
mockIcons(hass);
|
||||
mockAuth(hass);
|
||||
mockHistory(hass);
|
||||
mockRecorder(hass);
|
||||
mockTemplate(hass);
|
||||
mockSensor(hass);
|
||||
mockMediaPlayer(hass);
|
||||
mockTodo(hass);
|
||||
mockWeather(hass);
|
||||
mockLogbook(hass);
|
||||
hass.updateHass({
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [...hass.config.components, "logbook"],
|
||||
},
|
||||
});
|
||||
mockCalendarEvents(hass, (entityId, currentHass) => {
|
||||
if (!(entityId in currentHass.states)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const calendarIds = Object.keys(currentHass.states).filter((id) =>
|
||||
id.startsWith("calendar.")
|
||||
);
|
||||
|
||||
return (
|
||||
this.calendars?.[entityId] ??
|
||||
SAMPLE_CALENDAR_EVENTS[
|
||||
calendarIds.indexOf(entityId) % SAMPLE_CALENDAR_EVENTS.length
|
||||
]
|
||||
);
|
||||
});
|
||||
hass.mockWS("config/area_registry/list", (_msg, currentHass) =>
|
||||
Object.values(currentHass.areas)
|
||||
);
|
||||
hass.mockWS("config/device_registry/list", (_msg, currentHass) =>
|
||||
Object.values(currentHass.devices)
|
||||
);
|
||||
// The registry of the entities, with the options each entity sets, such
|
||||
// as timer presets. Other settings, such as calendar colors, are not set.
|
||||
const registryEntries = (currentHass: MockHomeAssistant) =>
|
||||
Object.values(currentHass.entities).map(
|
||||
(entity): EntityRegistryEntry => ({
|
||||
...newEntry({
|
||||
entity_id: entity.entity_id,
|
||||
platform: entity.platform ?? "demo",
|
||||
unique_id: entity.entity_id,
|
||||
}),
|
||||
area_id: entity.area_id ?? null,
|
||||
device_id: entity.device_id ?? null,
|
||||
options:
|
||||
this.entities.find((item) => item.entity_id === entity.entity_id)
|
||||
?.options ?? null,
|
||||
})
|
||||
);
|
||||
hass.mockWS("config/entity_registry/list", (_msg, currentHass) =>
|
||||
registryEntries(currentHass)
|
||||
);
|
||||
hass.mockWS(
|
||||
"config/entity_registry/get",
|
||||
(msg: { entity_id: string }, currentHass: MockHomeAssistant) => {
|
||||
const entry = registryEntries(currentHass).find(
|
||||
(item) => item.entity_id === msg.entity_id
|
||||
);
|
||||
if (!entry) {
|
||||
throw new Error(`Entity not found: ${msg.entity_id}`);
|
||||
}
|
||||
return extEntry(entry);
|
||||
}
|
||||
);
|
||||
mockExtractFromTarget(hass);
|
||||
hass.mockWS("energy/get_prefs", (): EnergyPreferences => ({
|
||||
...EMPTY_PREFERENCES,
|
||||
...this.energy,
|
||||
}));
|
||||
hass.mockWS("energy/info", (): EnergyInfo => ({
|
||||
cost_sensors: {},
|
||||
solar_forecast_domains: [],
|
||||
}));
|
||||
this._applyColorScheme();
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._unsubMediaQueries = [
|
||||
listenMediaQuery("(prefers-color-scheme: dark)", (matches) => {
|
||||
this._prefersDark = matches;
|
||||
this._applyColorScheme();
|
||||
}),
|
||||
listenMediaQuery("(prefers-reduced-motion: reduce)", (matches) =>
|
||||
this._reducedMotion.setValue(matches)
|
||||
),
|
||||
];
|
||||
|
||||
// Only once, so that moving the provider keeps the current entities
|
||||
if (this._scriptRead) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._scriptRead = true;
|
||||
|
||||
const data = readScriptData(this) as
|
||||
EntityInput[] | EmbedProviderData | undefined;
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
this.entities = data;
|
||||
} else if (data) {
|
||||
this.entities = data.entities ?? [];
|
||||
this.areas = data.areas;
|
||||
this.devices = data.devices;
|
||||
this.calendars = data.calendars;
|
||||
this.energy = data.energy;
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubMediaQueries.forEach((unsub) => unsub());
|
||||
this._unsubMediaQueries = [];
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
if (changedProps.has("colorScheme")) {
|
||||
this._applyColorScheme();
|
||||
}
|
||||
|
||||
if (
|
||||
changedProps.has("entities") ||
|
||||
changedProps.has("areas") ||
|
||||
changedProps.has("devices")
|
||||
) {
|
||||
const devices = this.devices ?? [];
|
||||
const areas: DemoArea[] =
|
||||
this.areas ??
|
||||
[
|
||||
...new Set(
|
||||
[...this.entities, ...devices].flatMap((item) =>
|
||||
item.area_id === undefined ? [] : [item.area_id]
|
||||
)
|
||||
),
|
||||
].map((areaId) => ({ area_id: areaId, name: areaName(areaId) }));
|
||||
|
||||
const areasById: Record<string, AreaRegistryEntry> = {};
|
||||
areas.forEach((area) => {
|
||||
areasById[area.area_id] = toAreaRegistryEntry(area);
|
||||
});
|
||||
const devicesById: Record<string, DeviceRegistryEntry> = {};
|
||||
devices.forEach((device) => {
|
||||
devicesById[device.id] = toDeviceRegistryEntry(device);
|
||||
});
|
||||
// Before the entities, so that their names can include the area and
|
||||
// device
|
||||
this.hass.updateHass({ areas: areasById, devices: devicesById });
|
||||
|
||||
// Entities that services changed keep their state when only the areas
|
||||
// or devices change
|
||||
if (changedProps.has("entities")) {
|
||||
this.hass.addEntities(this.entities, true);
|
||||
} else {
|
||||
this.hass.updateFormatFunctions();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
const oldHass = changedProps.get("hass");
|
||||
// The first update holds the initial entities. They are not changes.
|
||||
if (!oldHass) {
|
||||
return;
|
||||
}
|
||||
// Other updates, such as loaded translations, keep the same states object.
|
||||
if (oldHass.states !== this.hass.states) {
|
||||
fireEvent(this, "states-changed", { states: this.hass.states });
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`<slot></slot>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-embed-provider": HaEmbedProvider;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"states-changed": { states: HassEntities };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
|
||||
import { normalizeSectionConfig } from "../../../src/data/lovelace/config/normalize";
|
||||
import type { LovelaceSectionRawConfig } from "../../../src/data/lovelace/config/section";
|
||||
import "../../../src/panels/lovelace/sections/hui-section";
|
||||
import {
|
||||
renderSectionContainer,
|
||||
sectionContainerStyles,
|
||||
} from "../../../src/panels/lovelace/sections/section-container";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { EmbedElement } from "./embed-element";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
import { embedElementStyles } from "./theme-styles";
|
||||
|
||||
/**
|
||||
* Renders a dashboard section, or a section strategy, with the backend of a
|
||||
* provider, see `EmbedElement`. The section takes the width of the element.
|
||||
*/
|
||||
@customElement("ha-embed-section")
|
||||
export class HaEmbedSection extends EmbedElement<LovelaceSectionRawConfig> {
|
||||
// A title becomes a heading card, as on a dashboard
|
||||
private _normalizedConfig = memoizeOne(normalizeSectionConfig);
|
||||
|
||||
// A section has cards, sections inside it, a strategy that makes them, a
|
||||
// title, or the type of a custom section
|
||||
protected isConfig(data: ScriptObject): data is LovelaceSectionRawConfig {
|
||||
return (
|
||||
"cards" in data ||
|
||||
"sections" in data ||
|
||||
"strategy" in data ||
|
||||
"title" in data ||
|
||||
("type" in data && typeof data.type === "string")
|
||||
);
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
// The section that hid the element is gone, and the next one starts
|
||||
// visible without telling it
|
||||
if (changedProps.has("config") && !this.config) {
|
||||
this.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
// In the container that draws the background of the section, as in a
|
||||
// sections view
|
||||
protected renderContent(hass: HomeAssistant) {
|
||||
if (!this.config) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const config = this._normalizedConfig(this.config);
|
||||
|
||||
return renderSectionContainer(
|
||||
hass,
|
||||
config,
|
||||
html`<hui-section
|
||||
.hass=${hass}
|
||||
.config=${config}
|
||||
@section-visibility-changed=${this._visibilityChanged}
|
||||
></hui-section>`
|
||||
);
|
||||
}
|
||||
|
||||
private _visibilityChanged(
|
||||
ev: HASSDomEvent<HASSDomEvents["section-visibility-changed"]>
|
||||
) {
|
||||
// Sections inside the section fire the same event
|
||||
if (ev.target !== ev.currentTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hidden = !ev.detail.value;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
embedElementStyles,
|
||||
sectionContainerStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-embed-section": HaEmbedSection;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { keyed } from "lit/directives/keyed";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type {
|
||||
LovelaceConfig,
|
||||
LovelaceRawConfig,
|
||||
} from "../../../src/data/lovelace/config/types";
|
||||
import type { LovelaceViewRawConfig } from "../../../src/data/lovelace/config/view";
|
||||
import "../../../src/panels/lovelace/views/hui-view";
|
||||
import "../../../src/panels/lovelace/views/hui-view-background";
|
||||
import "../../../src/panels/lovelace/views/hui-view-container";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import {
|
||||
EmbedLovelaceElement,
|
||||
findViewIndex,
|
||||
isDashboardConfig,
|
||||
} from "./lovelace";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
|
||||
/**
|
||||
* Renders a dashboard view, a view strategy, or one view of a dashboard or
|
||||
* dashboard strategy, with the backend of a provider, see `EmbedElement`.
|
||||
* `config` holds the view.
|
||||
*/
|
||||
@customElement("ha-embed-view")
|
||||
export class HaEmbedView extends EmbedLovelaceElement<LovelaceViewRawConfig> {
|
||||
/**
|
||||
* A dashboard configuration or dashboard strategy, to show its `view`
|
||||
* instead of `config`. Also read from the `dashboard` key of a script child,
|
||||
* next to `view`.
|
||||
*/
|
||||
@property({ type: Object }) public dashboard?: LovelaceRawConfig;
|
||||
|
||||
private _viewDashboard = memoizeOne(
|
||||
(view: LovelaceViewRawConfig): LovelaceConfig => ({ views: [view] })
|
||||
);
|
||||
|
||||
protected get rawConfig() {
|
||||
return this.dashboard ?? (this.config && this._viewDashboard(this.config));
|
||||
}
|
||||
|
||||
// A view strategy and a dashboard strategy both have a `strategy` key, so
|
||||
// a dashboard comes in its own key, which no view has
|
||||
protected isConfig(data: ScriptObject): data is LovelaceViewRawConfig {
|
||||
return !("dashboard" in data);
|
||||
}
|
||||
|
||||
protected setScriptData(data: ScriptObject) {
|
||||
if (!("dashboard" in data)) {
|
||||
super.setScriptData(data);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { dashboard } = data;
|
||||
|
||||
if (
|
||||
typeof dashboard === "object" &&
|
||||
dashboard !== null &&
|
||||
isDashboardConfig(dashboard)
|
||||
) {
|
||||
this.dashboard = dashboard;
|
||||
}
|
||||
|
||||
if (
|
||||
"view" in data &&
|
||||
(typeof data.view === "string" || typeof data.view === "number")
|
||||
) {
|
||||
this.view ??= String(data.view);
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing for a dashboard without views, such as an empty strategy result.
|
||||
// The view's theme and background, or the dashboard's background, as in
|
||||
// hui-root. Each view gets its own hui-view, as there, so that a view
|
||||
// strategy still generating for the view before can't replace the cards of
|
||||
// this one.
|
||||
protected renderContent(hass: HomeAssistant) {
|
||||
const views = this._lovelace?.config.views;
|
||||
|
||||
if (!this._lovelace || !views?.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const index = findViewIndex(views, this.view);
|
||||
const viewConfig = views[index];
|
||||
|
||||
const background =
|
||||
viewConfig.background || this._lovelace.config.background;
|
||||
|
||||
return html`<hui-view-container .hass=${hass} .theme=${viewConfig.theme}>
|
||||
<hui-view-background .hass=${hass} .background=${background}>
|
||||
</hui-view-background>
|
||||
${keyed(
|
||||
viewConfig,
|
||||
html`<hui-view
|
||||
.hass=${hass}
|
||||
.lovelace=${this._lovelace}
|
||||
.index=${index}
|
||||
.narrow=${this._narrow.value ?? false}
|
||||
></hui-view>`
|
||||
)}
|
||||
</hui-view-container>`;
|
||||
}
|
||||
|
||||
// A fixed background stays inside the element. With a height set on the
|
||||
// element, a panel view fills it, and other views keep their own height.
|
||||
static styles = [
|
||||
EmbedLovelaceElement.styles,
|
||||
css`
|
||||
:host {
|
||||
contain: layout paint;
|
||||
}
|
||||
.theme,
|
||||
hui-view-container,
|
||||
hui-view {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
/* The background sits below the view, above the element's own */
|
||||
hui-view-container {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-embed-view": HaEmbedView;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css } from "lit";
|
||||
import { property, state } from "lit/decorators";
|
||||
import { normalizeLovelaceConfig } from "../../../src/data/lovelace/config/normalize";
|
||||
import type { LovelaceRawConfig } from "../../../src/data/lovelace/config/types";
|
||||
import { isStrategyDashboard } from "../../../src/data/lovelace/config/types";
|
||||
import type { LovelaceViewRawConfig } from "../../../src/data/lovelace/config/view";
|
||||
import {
|
||||
checkStrategyShouldRegenerate,
|
||||
generateLovelaceDashboardStrategy,
|
||||
} from "../../../src/panels/lovelace/strategies/get-strategy";
|
||||
import type { Lovelace } from "../../../src/panels/lovelace/types";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { EmbedElement } from "./embed-element";
|
||||
import type { ScriptObject } from "./script-data";
|
||||
import { embedElementStyles } from "./theme-styles";
|
||||
|
||||
// The width below which the app is narrow, see home-assistant-main
|
||||
const NARROW_WIDTH = 870;
|
||||
|
||||
/** The index of the view with this path or index, if there is one. */
|
||||
const viewIndex = (
|
||||
views: LovelaceViewRawConfig[],
|
||||
view: string
|
||||
): number | undefined => {
|
||||
const byPath = views.findIndex((item) => item.path === view);
|
||||
|
||||
if (byPath !== -1) {
|
||||
return byPath;
|
||||
}
|
||||
|
||||
const index = Number(view);
|
||||
|
||||
return Number.isInteger(index) && index >= 0 && index < views.length
|
||||
? index
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Whether script data is a dashboard: its views, or a strategy for them. */
|
||||
export const isDashboardConfig = (
|
||||
data: ScriptObject
|
||||
): data is LovelaceRawConfig => "views" in data || "strategy" in data;
|
||||
|
||||
/** The index of the view with this path or index, else the first view. */
|
||||
export const findViewIndex = (
|
||||
views: LovelaceViewRawConfig[],
|
||||
view?: string
|
||||
): number => (view === undefined ? 0 : (viewIndex(views, view) ?? 0));
|
||||
|
||||
/**
|
||||
* The base of the embed elements that render a dashboard, or one of its
|
||||
* views. It generates dashboard strategies, again when the registries they
|
||||
* depend on change, and holds a dashboard that can't be edited or saved.
|
||||
*/
|
||||
export abstract class EmbedLovelaceElement<
|
||||
Config extends ScriptObject,
|
||||
> extends EmbedElement<Config> {
|
||||
/** The path or index of the view to show. Without it, the first view. */
|
||||
@property() public view?: string;
|
||||
|
||||
@state() protected _lovelace?: Lovelace;
|
||||
|
||||
// Narrow like the app at the same width, measured on the element because
|
||||
// it rarely takes the width of the page
|
||||
protected _narrow = new ResizeController(this, {
|
||||
callback: (entries) =>
|
||||
(entries[0]?.contentRect.width ?? NARROW_WIDTH) < NARROW_WIDTH,
|
||||
});
|
||||
|
||||
// The configuration of the dashboard in `_lovelace`, or being generated
|
||||
private _rawConfig?: LovelaceRawConfig;
|
||||
|
||||
// Counts the generations started, so that only the latest one is shown.
|
||||
// Generating again for changed registries keeps the same configuration.
|
||||
private _generation = 0;
|
||||
|
||||
// The backend at the last update, to tell what a strategy depends on changed
|
||||
private _previousHass?: HomeAssistant;
|
||||
|
||||
/** The dashboard to render. The same object until it changes. */
|
||||
protected abstract get rawConfig(): LovelaceRawConfig | undefined;
|
||||
|
||||
/**
|
||||
* The view of the dashboard a path goes to, relative like `batteries` or
|
||||
* with the dashboard like `/maintenance/batteries`, if it has that view.
|
||||
*/
|
||||
protected viewOfPath(path: string | undefined): string | undefined {
|
||||
const views = this._lovelace?.config.views;
|
||||
|
||||
const view = path
|
||||
? decodeURI(new URL(path, "http://embed/").pathname)
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.pop()
|
||||
: undefined;
|
||||
|
||||
return views && view !== undefined && viewIndex(views, view) !== undefined
|
||||
? view
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Whether two paths or indexes go to the same view of the dashboard. */
|
||||
protected isSameView(a?: string, b?: string): boolean {
|
||||
const views = this._lovelace?.config.views;
|
||||
|
||||
return views
|
||||
? findViewIndex(views, a) === findViewIndex(views, b)
|
||||
: a === b;
|
||||
}
|
||||
|
||||
// A path to one of the dashboard's views shows that view. Other paths leave
|
||||
// the dashboard where it is.
|
||||
protected navigateTo(path: string) {
|
||||
const view = this.viewOfPath(path);
|
||||
|
||||
if (view !== undefined) {
|
||||
this.view = view;
|
||||
}
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
const raw = this.rawConfig;
|
||||
const oldHass = this._previousHass;
|
||||
|
||||
this._previousHass = this._hass;
|
||||
|
||||
// Without a configuration, nothing is shown, and a generation still
|
||||
// running is dropped
|
||||
if (!raw) {
|
||||
if (this._rawConfig) {
|
||||
this._rawConfig = undefined;
|
||||
this._lovelace = undefined;
|
||||
this._generation++;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A strategy is only asked whether to generate again when the backend
|
||||
// changed, as in ha-panel-lovelace, not when the generated dashboard
|
||||
// arrives
|
||||
if (
|
||||
raw !== this._rawConfig ||
|
||||
(oldHass &&
|
||||
oldHass !== this._hass &&
|
||||
isStrategyDashboard(raw) &&
|
||||
this._hass.config.state === "RUNNING" &&
|
||||
checkStrategyShouldRegenerate(
|
||||
"dashboard",
|
||||
raw.strategy,
|
||||
oldHass,
|
||||
this._hass
|
||||
))
|
||||
) {
|
||||
this._rawConfig = raw;
|
||||
this._generate(raw, this._hass, ++this._generation);
|
||||
}
|
||||
}
|
||||
|
||||
private async _generate(
|
||||
raw: LovelaceRawConfig,
|
||||
hass: HomeAssistant,
|
||||
generation: number
|
||||
) {
|
||||
const generated = isStrategyDashboard(raw)
|
||||
? await generateLovelaceDashboardStrategy(raw, hass)
|
||||
: raw;
|
||||
|
||||
const config = normalizeLovelaceConfig(generated);
|
||||
|
||||
// A newer generation has started. A generated dashboard has its views,
|
||||
// rather than a strategy.
|
||||
if (generation !== this._generation || isStrategyDashboard(config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._lovelace = {
|
||||
config,
|
||||
rawConfig: raw,
|
||||
editMode: false,
|
||||
urlPath: null,
|
||||
mode: "generated",
|
||||
locale: hass.locale,
|
||||
enableFullEditMode: () => undefined,
|
||||
saveConfig: async () => undefined,
|
||||
deleteConfig: async () => undefined,
|
||||
setEditMode: () => undefined,
|
||||
showToast: () => undefined,
|
||||
embedded: true,
|
||||
};
|
||||
}
|
||||
|
||||
// The background of a dashboard, and no insets for a notch or home bar
|
||||
static styles: CSSResultGroup = [
|
||||
embedElementStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
--app-safe-area-inset-top: 0px;
|
||||
--app-safe-area-inset-bottom: 0px;
|
||||
--app-safe-area-inset-left: 0px;
|
||||
--app-safe-area-inset-right: 0px;
|
||||
}
|
||||
.theme {
|
||||
position: relative;
|
||||
background: var(--primary-background-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
declare global {
|
||||
var __HA_STATIC_PATH__: string | undefined;
|
||||
}
|
||||
declare let __webpack_public_path__: string;
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
|
||||
// Load chunks and static files from the server that hosts this script, so
|
||||
// that other sites can embed the cards. Only the modern production build keeps
|
||||
// `import.meta.url` (see build-scripts/rspack.cjs). The legacy and development
|
||||
// builds only run on the demo site, where the default paths are correct.
|
||||
// `new URL(path, import.meta.url)` in one expression is an asset reference for
|
||||
// the bundler, so the script URL goes into a variable first.
|
||||
if (__BUILD__ === "modern" && !__DEV__) {
|
||||
const scriptUrl = import.meta.url;
|
||||
__webpack_public_path__ = new URL(".", scriptUrl).href;
|
||||
globalThis.__HA_STATIC_PATH__ = new URL("../static/", scriptUrl).href;
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { load } from "js-yaml";
|
||||
|
||||
/**
|
||||
* Parses the data in a `<script type="application/json">` or
|
||||
* `<script type="text/yaml">` child of the element.
|
||||
*/
|
||||
export const readScriptData = (element: HTMLElement): unknown => {
|
||||
const script = element.querySelector<HTMLScriptElement>(
|
||||
':scope > script[type="application/json"], :scope > script[type="text/yaml"]'
|
||||
);
|
||||
if (!script) {
|
||||
return undefined;
|
||||
}
|
||||
return script.type === "text/yaml"
|
||||
? load(script.textContent!)
|
||||
: JSON.parse(script.textContent!);
|
||||
};
|
||||
|
||||
/** The data of a script child that holds an object, such as a config. */
|
||||
export type ScriptObject = object;
|
||||
|
||||
/** The object in a script child, see `readScriptData`, if it holds one. */
|
||||
export const readScriptObject = (
|
||||
element: HTMLElement
|
||||
): ScriptObject | undefined => {
|
||||
const data = readScriptData(element);
|
||||
|
||||
return typeof data === "object" && data !== null && !Array.isArray(data)
|
||||
? data
|
||||
: undefined;
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { css, unsafeCSS } from "lit";
|
||||
import { hex2rgb } from "../../../src/common/color/convert-color";
|
||||
import { darkColorVariables } from "../../../src/resources/theme/color";
|
||||
import { darkSemanticVariables } from "../../../src/resources/theme/semantic.globals";
|
||||
import { themeStyles } from "../../../src/resources/theme/theme";
|
||||
|
||||
// The theme styles set variables on `html`, next to fonts and other page
|
||||
// styles. Only the variables, media queries, and keyframes are copied, and
|
||||
// `html` becomes `selector`. This keeps the theme inside the card, so that it
|
||||
// does not change the page that embeds the card.
|
||||
const scopeThemeStyles = (cssText: string, selector: string): string => {
|
||||
// A style element in a separate document parses the CSS without applying it.
|
||||
const doc = document.implementation.createHTMLDocument("");
|
||||
const style = doc.createElement("style");
|
||||
style.textContent = cssText;
|
||||
doc.head.append(style);
|
||||
|
||||
const convert = (rules: CSSRuleList): string =>
|
||||
Array.from(rules, (rule) => {
|
||||
if (rule instanceof CSSStyleRule) {
|
||||
if (rule.selectorText !== "html") {
|
||||
return "";
|
||||
}
|
||||
const variables = Array.from(rule.style)
|
||||
.filter((name) => name.startsWith("--"))
|
||||
.map((name) => `${name}:${rule.style.getPropertyValue(name)};`);
|
||||
return `${selector}{${variables.join("")}}`;
|
||||
}
|
||||
if (rule instanceof CSSMediaRule) {
|
||||
return `@media ${rule.conditionText}{${convert(rule.cssRules)}}`;
|
||||
}
|
||||
if (rule instanceof CSSKeyframesRule) {
|
||||
return rule.cssText;
|
||||
}
|
||||
return "";
|
||||
}).join("");
|
||||
|
||||
return convert(style.sheet!.cssRules);
|
||||
};
|
||||
|
||||
const darkTheme = {
|
||||
...darkSemanticVariables,
|
||||
...darkColorVariables,
|
||||
};
|
||||
|
||||
// Like processTheme in apply_themes_on_element, each hex color also sets its
|
||||
// `--rgb-` variable, else those keep their light theme values
|
||||
const darkVariables = Object.entries(darkTheme).flatMap(([name, value]) => {
|
||||
const variable = `--${name}:${value};`;
|
||||
|
||||
if (!value.startsWith("#") || `rgb-${name}` in darkTheme) {
|
||||
return [variable];
|
||||
}
|
||||
|
||||
return [variable, `--rgb-${name}:${hex2rgb(value).join(",")};`];
|
||||
});
|
||||
|
||||
export const embedThemeStyles = unsafeCSS(
|
||||
scopeThemeStyles(themeStyles, ".theme") +
|
||||
`.theme.dark{${darkVariables.join("")}}`
|
||||
);
|
||||
|
||||
/**
|
||||
* The styles of every embed element: the theme, and the page styles of the
|
||||
* app, for the `.theme` wrapper of `EmbedElement`.
|
||||
*/
|
||||
export const embedElementStyles = [
|
||||
embedThemeStyles,
|
||||
css`
|
||||
:host([hidden]) {
|
||||
display: none;
|
||||
}
|
||||
/* The text styles the app sets on html and body, so that the content does
|
||||
not inherit the text styles of the page that embeds it. */
|
||||
.theme {
|
||||
color: var(--primary-text-color);
|
||||
font-family: var(--ha-font-family-body);
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-body);
|
||||
line-height: normal;
|
||||
letter-spacing: normal;
|
||||
text-align: start;
|
||||
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
|
||||
-webkit-font-smoothing: var(--ha-font-smoothing);
|
||||
}
|
||||
/* What setDirectionStyles sets on the app for a right-to-left language,
|
||||
here only for the content */
|
||||
.theme[dir="rtl"] {
|
||||
--direction: rtl;
|
||||
--float-start: right;
|
||||
--float-end: left;
|
||||
--scale-direction: -1;
|
||||
}
|
||||
`,
|
||||
];
|
||||
@@ -0,0 +1,32 @@
|
||||
// Browsers only start a worker from a script on the page's own origin, and
|
||||
// the workers of the cards, such as the markdown worker, are served next to
|
||||
// this script. A worker from another origin starts from a blob instead, which
|
||||
// loads the real worker script. The demo serves its scripts with CORS.
|
||||
const pageWorker = globalThis.Worker;
|
||||
|
||||
globalThis.Worker = class extends pageWorker {
|
||||
constructor(scriptUrl: string | URL, options?: WorkerOptions) {
|
||||
const url = new URL(scriptUrl, location.href);
|
||||
|
||||
if (url.origin === location.origin) {
|
||||
super(scriptUrl, options);
|
||||
return;
|
||||
}
|
||||
|
||||
const source =
|
||||
options?.type === "module"
|
||||
? `import ${JSON.stringify(url.href)};`
|
||||
: `importScripts(${JSON.stringify(url.href)});`;
|
||||
const blobUrl = URL.createObjectURL(
|
||||
new Blob([source], { type: "text/javascript" })
|
||||
);
|
||||
|
||||
try {
|
||||
super(blobUrl, options);
|
||||
} finally {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export {};
|
||||
@@ -36,6 +36,7 @@ import { mockHardware } from "./stubs/hardware";
|
||||
import { mockHassioSupervisor } from "./stubs/hassio_supervisor";
|
||||
import { mockIntegration } from "./stubs/integration";
|
||||
import { mockLabelRegistry } from "./stubs/label_registry";
|
||||
import { mockLogbook } from "./stubs/logbook";
|
||||
import { automationPlatformDomains } from "./stubs/automation_platform_domains";
|
||||
import { mockIcons } from "./stubs/icons";
|
||||
import { mockHistory } from "./stubs/history";
|
||||
@@ -50,6 +51,7 @@ import { mockTemplate } from "./stubs/template";
|
||||
import { mockTodo } from "./stubs/todo";
|
||||
import { mockTranslations } from "./stubs/translations";
|
||||
import { mockUsagePrediction } from "./stubs/usage_prediction";
|
||||
import { mockWeather } from "./stubs/weather";
|
||||
|
||||
// WS command / REST path prefixes whose mocks live in the lazily imported
|
||||
// config-panel chunk (see ./stubs/config-panel). Must stay in sync with it.
|
||||
@@ -75,6 +77,7 @@ const CONFIG_PANEL_COMMANDS = [
|
||||
"get_conditions_for_target",
|
||||
"get_services_for_target",
|
||||
"extract_from_target",
|
||||
"explain_target",
|
||||
"script/config",
|
||||
"config/automation/config",
|
||||
"config/script/config",
|
||||
@@ -190,7 +193,9 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
mockSystemLog(hass);
|
||||
mockTemplate(hass);
|
||||
mockEvents(hass);
|
||||
mockLogbook(hass);
|
||||
mockMediaPlayer(hass);
|
||||
mockWeather(hass);
|
||||
mockFrontend(hass);
|
||||
mockHardware(hass);
|
||||
mockHassioSupervisor(hass);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
import "<%= latestEntryJS[0] %>";
|
||||
@@ -12,6 +12,20 @@ export interface DemoArea {
|
||||
|
||||
let areas: AreaRegistryEntry[] = [];
|
||||
|
||||
export const toAreaRegistryEntry = (area: DemoArea): AreaRegistryEntry => ({
|
||||
area_id: area.area_id,
|
||||
name: area.name,
|
||||
floor_id: area.floor_id ?? null,
|
||||
icon: area.icon ?? null,
|
||||
temperature_entity_id: area.temperature_entity_id ?? null,
|
||||
humidity_entity_id: area.humidity_entity_id ?? null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
picture: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
});
|
||||
|
||||
export const mockAreaRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
data: DemoArea[] = []
|
||||
@@ -25,19 +39,7 @@ export const setDemoAreas = (
|
||||
hass: MockHomeAssistant,
|
||||
demoAreas: DemoArea[] = []
|
||||
) => {
|
||||
areas = demoAreas.map((area) => ({
|
||||
area_id: area.area_id,
|
||||
name: area.name,
|
||||
floor_id: area.floor_id ?? null,
|
||||
icon: area.icon ?? null,
|
||||
temperature_entity_id: area.temperature_entity_id ?? null,
|
||||
humidity_entity_id: area.humidity_entity_id ?? null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
picture: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
}));
|
||||
areas = demoAreas.map(toAreaRegistryEntry);
|
||||
const areasById: Record<string, AreaRegistryEntry> = {};
|
||||
areas.forEach((area) => {
|
||||
areasById[area.area_id] = area;
|
||||
|
||||
@@ -2,14 +2,14 @@ import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { ensureArray } from "../../../src/common/array/ensure-array";
|
||||
import { computeDomain } from "../../../src/common/entity/compute_domain";
|
||||
import type { ConditionDescriptions } from "../../../src/data/condition";
|
||||
import type { ExtractFromTargetResult } from "../../../src/data/target";
|
||||
import type { TriggerDescriptions } from "../../../src/data/trigger";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { getLabelIds } from "./label_registry";
|
||||
import {
|
||||
conditionDescriptions,
|
||||
triggerDescriptions,
|
||||
} from "./automation_platforms_data";
|
||||
import { explainTarget } from "./explain_target";
|
||||
import { expandTarget, mockExtractFromTarget } from "./target";
|
||||
|
||||
type Descriptions = TriggerDescriptions | ConditionDescriptions;
|
||||
|
||||
@@ -20,109 +20,6 @@ interface EntityFilter {
|
||||
supported_features?: number[];
|
||||
}
|
||||
|
||||
// Expands a target to its areas, devices and entities, like core does
|
||||
const expandTarget = (
|
||||
hass: MockHomeAssistant,
|
||||
target: HassServiceTarget,
|
||||
primaryEntitiesOnly = true
|
||||
) => {
|
||||
const missing = {
|
||||
floors: [] as string[],
|
||||
areas: [] as string[],
|
||||
devices: [] as string[],
|
||||
labels: [] as string[],
|
||||
};
|
||||
const knownLabelIds = getLabelIds();
|
||||
const floorIds = ensureArray(target.floor_id ?? []);
|
||||
const labelIds = ensureArray(target.label_id ?? []);
|
||||
missing.floors.push(...floorIds.filter((id) => !(id in hass.floors)));
|
||||
// Like core, unknown labels are reported, but still expanded
|
||||
missing.labels.push(...labelIds.filter((id) => !knownLabelIds.includes(id)));
|
||||
const hasLabel = (labels?: string[]) =>
|
||||
labelIds.some((labelId) => labels?.includes(labelId));
|
||||
|
||||
// Unknown areas and devices are reported, but still referenced
|
||||
const areaIds = new Set(ensureArray(target.area_id ?? []));
|
||||
missing.areas.push(...[...areaIds].filter((id) => !(id in hass.areas)));
|
||||
for (const area of Object.values(hass.areas)) {
|
||||
if (
|
||||
(area.floor_id && floorIds.includes(area.floor_id)) ||
|
||||
hasLabel(area.labels)
|
||||
) {
|
||||
areaIds.add(area.area_id);
|
||||
}
|
||||
}
|
||||
|
||||
// A targeted or labeled device includes its child devices
|
||||
const deviceIds = new Set<string>();
|
||||
const addDevice = (deviceId: string) => {
|
||||
deviceIds.add(deviceId);
|
||||
for (const device of Object.values(hass.devices)) {
|
||||
if (device.parent_device_id === deviceId) {
|
||||
deviceIds.add(device.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const deviceId of ensureArray(target.device_id ?? [])) {
|
||||
if (!(deviceId in hass.devices)) {
|
||||
missing.devices.push(deviceId);
|
||||
}
|
||||
addDevice(deviceId);
|
||||
}
|
||||
for (const device of Object.values(hass.devices)) {
|
||||
if (hasLabel(device.labels)) {
|
||||
addDevice(device.id);
|
||||
}
|
||||
}
|
||||
|
||||
// A child device without its own area is in the area of its parent device
|
||||
const deviceArea = (deviceId: string) => {
|
||||
const device = hass.devices[deviceId];
|
||||
return (
|
||||
device?.area_id ??
|
||||
(device?.parent_device_id
|
||||
? hass.devices[device.parent_device_id]?.area_id
|
||||
: undefined)
|
||||
);
|
||||
};
|
||||
const areaDeviceIds = new Set<string>();
|
||||
for (const device of Object.values(hass.devices)) {
|
||||
const areaId = deviceArea(device.id);
|
||||
if (areaId && areaIds.has(areaId)) {
|
||||
areaDeviceIds.add(device.id);
|
||||
}
|
||||
}
|
||||
|
||||
const entityIds = new Set(ensureArray(target.entity_id ?? []));
|
||||
for (const entity of Object.values(hass.entities)) {
|
||||
// The display registry keeps the entities of a previous demo, and like
|
||||
// core, hidden entities are never included indirectly
|
||||
if (!(entity.entity_id in hass.states) || entity.hidden) {
|
||||
continue;
|
||||
}
|
||||
const primary = !primaryEntitiesOnly || !entity.entity_category;
|
||||
if (
|
||||
hasLabel(entity.labels) ||
|
||||
(primary &&
|
||||
((entity.device_id && deviceIds.has(entity.device_id)) ||
|
||||
(entity.area_id && areaIds.has(entity.area_id)) ||
|
||||
// An entity with its own area is not in the area of its device
|
||||
(!entity.area_id &&
|
||||
entity.device_id &&
|
||||
areaDeviceIds.has(entity.device_id))))
|
||||
) {
|
||||
entityIds.add(entity.entity_id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
areas: [...areaIds],
|
||||
devices: [...new Set([...deviceIds, ...areaDeviceIds])],
|
||||
entities: [...entityIds],
|
||||
missing,
|
||||
};
|
||||
};
|
||||
|
||||
const matchesFilter = (
|
||||
hass: MockHomeAssistant,
|
||||
entityId: string,
|
||||
@@ -228,27 +125,20 @@ export const mockAutomationPlatforms = (hass: MockHomeAssistant) => {
|
||||
);
|
||||
mockForTarget(hass, "get_triggers_for_target", triggerDescriptions);
|
||||
mockForTarget(hass, "get_conditions_for_target", conditionDescriptions);
|
||||
mockExtractFromTarget(hass);
|
||||
hass.mockWS(
|
||||
"extract_from_target",
|
||||
"explain_target",
|
||||
(
|
||||
msg: { target: HassServiceTarget; primary_entities_only?: boolean },
|
||||
currentHass: MockHomeAssistant
|
||||
): ExtractFromTargetResult => {
|
||||
const { areas, devices, entities, missing } = expandTarget(
|
||||
currentHass,
|
||||
) =>
|
||||
explainTarget(
|
||||
msg.target,
|
||||
(target, primaryEntitiesOnly) =>
|
||||
expandTarget(currentHass, target, primaryEntitiesOnly),
|
||||
Object.keys(currentHass.states),
|
||||
msg.primary_entities_only ?? true
|
||||
);
|
||||
return {
|
||||
missing_areas: missing.areas,
|
||||
missing_devices: missing.devices,
|
||||
missing_floors: missing.floors,
|
||||
missing_labels: missing.labels,
|
||||
referenced_areas: areas,
|
||||
referenced_devices: devices,
|
||||
referenced_entities: entities,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
hass.mockWS(
|
||||
"get_services_for_target",
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { CalendarEventApiData } from "../../../src/data/calendar";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
// Sample events relative to today, so demos always show something in the
|
||||
// current week and month.
|
||||
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
|
||||
const day = (offset: number): Date => {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
date.setDate(date.getDate() + offset);
|
||||
|
||||
return date;
|
||||
};
|
||||
|
||||
const isoDate = (date: Date) =>
|
||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
|
||||
const at = (offset: number, hours: number, minutes = 0) => {
|
||||
const date = day(offset);
|
||||
date.setHours(hours, minutes);
|
||||
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
const allDay = (offset: number, days = 1) => ({
|
||||
start: isoDate(day(offset)),
|
||||
end: isoDate(day(offset + days)),
|
||||
all_day: true,
|
||||
});
|
||||
|
||||
export const FAMILY_EVENTS: CalendarEventApiData[] = [
|
||||
{ summary: "School run", start: at(0, 8), end: at(0, 8, 30) },
|
||||
{
|
||||
summary: "Dentist",
|
||||
start: at(0, 15, 30),
|
||||
end: at(0, 16, 15),
|
||||
location: "Main Street 12",
|
||||
},
|
||||
{ summary: "Grandma's birthday", ...allDay(1) },
|
||||
{ summary: "Football practice", start: at(2, 17), end: at(2, 18, 30) },
|
||||
{
|
||||
summary: "Dinner with friends",
|
||||
start: at(3, 19),
|
||||
end: at(3, 22),
|
||||
status: "tentative",
|
||||
},
|
||||
{ summary: "Summer cabin", ...allDay(5, 3) },
|
||||
{ summary: "Parent-teacher meeting", start: at(9, 18), end: at(9, 19) },
|
||||
{ summary: "Football practice", start: at(9, 17), end: at(9, 18, 30) },
|
||||
{ summary: "Swimming lessons", start: at(12, 10), end: at(12, 11) },
|
||||
{ summary: "Car service", start: at(-3, 9), end: at(-3, 11) },
|
||||
{ summary: "Book club", start: at(-6, 19), end: at(-6, 21) },
|
||||
];
|
||||
|
||||
export const WORK_EVENTS: CalendarEventApiData[] = [
|
||||
{ summary: "Stand-up", start: at(0, 9), end: at(0, 9, 15) },
|
||||
{ summary: "Sprint planning", start: at(2, 10), end: at(2, 12) },
|
||||
{ summary: "Team lunch", start: at(4, 12), end: at(4, 13) },
|
||||
{ summary: "Conference", ...allDay(8, 2) },
|
||||
{ summary: "Quarterly review", start: at(-2, 14), end: at(-2, 15, 30) },
|
||||
];
|
||||
|
||||
const toTime = (value: CalendarEventApiData["start"]) =>
|
||||
new Date(
|
||||
typeof value === "string"
|
||||
? value
|
||||
: "dateTime" in value
|
||||
? value.dateTime
|
||||
: value.date
|
||||
).getTime();
|
||||
|
||||
const inRange = (
|
||||
events: CalendarEventApiData[],
|
||||
start: string | null,
|
||||
end: string | null
|
||||
): CalendarEventApiData[] => {
|
||||
const startTime = start ? new Date(start).getTime() : -Infinity;
|
||||
const endTime = end ? new Date(end).getTime() : Infinity;
|
||||
|
||||
return events.filter(
|
||||
(event) => toTime(event.end) > startTime && toTime(event.start) < endTime
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Answers event requests of calendar entities with the events of
|
||||
* `getEvents`. A calendar without events gets `null`, like a calendar whose
|
||||
* integration fails to return them.
|
||||
*/
|
||||
export const mockCalendarEvents = (
|
||||
hass: MockHomeAssistant,
|
||||
getEvents: (
|
||||
entityId: string,
|
||||
currentHass: MockHomeAssistant
|
||||
) => CalendarEventApiData[] | null | undefined
|
||||
) => {
|
||||
hass.mockWS("calendar/event/subscribe", (msg, currentHass, onChange) => {
|
||||
const events = getEvents(msg.entity_id, currentHass);
|
||||
onChange?.({ events: events ? inRange(events, msg.start, msg.end) : null });
|
||||
|
||||
return () => undefined;
|
||||
});
|
||||
// Home Assistant before 2026.5 has no event subscription, so cards that
|
||||
// support it ask the REST API instead.
|
||||
hass.mockAPI(/^calendars\/[^/?]+\?/, (currentHass, _method, path) => {
|
||||
const url = new URL(path, location.origin);
|
||||
const entityId = url.pathname.slice(url.pathname.lastIndexOf("/") + 1);
|
||||
const events = getEvents(entityId, currentHass);
|
||||
|
||||
if (!events) {
|
||||
return Promise.reject(new Error("Calendar not found"));
|
||||
}
|
||||
|
||||
return inRange(
|
||||
events,
|
||||
url.searchParams.get("start"),
|
||||
url.searchParams.get("end")
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -59,6 +59,10 @@ 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",
|
||||
|
||||
@@ -42,7 +42,13 @@ const REGISTRY_ENTRIES = [
|
||||
export const radioFrequencyFixtures: ConnectivityFixtures = {
|
||||
components: ["radio_frequency"],
|
||||
commands: ["radio_frequency/"],
|
||||
manifests: [manifest("esphome", "ESPHome", { integration_type: "device" })],
|
||||
manifests: [
|
||||
manifest("radio_frequency", "Radio frequency", {
|
||||
config_flow: false,
|
||||
integration_type: "entity",
|
||||
}),
|
||||
manifest("esphome", "ESPHome", { integration_type: "device" }),
|
||||
],
|
||||
configEntries: [
|
||||
{ type: "device", entry: configEntry(ENTRY_ID, "esphome", "RF Bridge") },
|
||||
],
|
||||
|
||||
@@ -1,6 +1,60 @@
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
/** The fields of a device that the demo devices leave at their default. */
|
||||
export const baseDevice: Omit<
|
||||
DeviceRegistryEntry,
|
||||
| "id"
|
||||
| "name"
|
||||
| "manufacturer"
|
||||
| "model"
|
||||
| "config_entries"
|
||||
| "primary_config_entry"
|
||||
| "entry_type"
|
||||
> = {
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
identifiers: [],
|
||||
model_id: null,
|
||||
labels: [],
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
serial_number: null,
|
||||
via_device_id: null,
|
||||
area_id: null,
|
||||
name_by_user: null,
|
||||
disabled_by: null,
|
||||
configuration_url: null,
|
||||
parent_device_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
|
||||
export interface DemoDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
area_id?: string;
|
||||
via_device_id?: string;
|
||||
entry_type?: "service";
|
||||
}
|
||||
|
||||
export const toDeviceRegistryEntry = (
|
||||
device: DemoDevice
|
||||
): DeviceRegistryEntry => ({
|
||||
...baseDevice,
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
manufacturer: device.manufacturer ?? null,
|
||||
model: device.model ?? null,
|
||||
area_id: device.area_id ?? null,
|
||||
via_device_id: device.via_device_id ?? null,
|
||||
entry_type: device.entry_type ?? null,
|
||||
config_entries: [],
|
||||
primary_config_entry: null,
|
||||
});
|
||||
|
||||
export const mockDeviceRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
data: DeviceRegistryEntry[] = []
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
import { connectivityDevices } from "./connectivity/fixtures";
|
||||
|
||||
const baseDevice = {
|
||||
config_entries_subentries: {},
|
||||
connections: [] as [string, string][],
|
||||
identifiers: [] as [string, string][],
|
||||
model_id: null,
|
||||
labels: [] as string[],
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
serial_number: null,
|
||||
via_device_id: null,
|
||||
area_id: null,
|
||||
name_by_user: null,
|
||||
disabled_by: null,
|
||||
configuration_url: null,
|
||||
parent_device_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
import { baseDevice } from "./device_registry";
|
||||
|
||||
export const demoDevices: DeviceRegistryEntry[] = [
|
||||
{
|
||||
|
||||
@@ -97,6 +97,14 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
|
||||
stat_consumption: "sensor.water_garden",
|
||||
},
|
||||
],
|
||||
device_consumption_gas: [
|
||||
{
|
||||
stat_consumption: "sensor.gas_kitchen",
|
||||
},
|
||||
{
|
||||
stat_consumption: "sensor.gas_heat_pump",
|
||||
},
|
||||
],
|
||||
}));
|
||||
hass.mockWS("energy/info", (): EnergyInfo => ({
|
||||
cost_sensors: {},
|
||||
@@ -106,6 +114,7 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
|
||||
energy_sources: Array.from({ length: 6 }, () => []),
|
||||
device_consumption: Array.from({ length: 6 }, () => []),
|
||||
device_consumption_water: Array.from({ length: 2 }, () => []),
|
||||
device_consumption_gas: Array.from({ length: 2 }, () => []),
|
||||
}));
|
||||
hass.mockWS(
|
||||
"energy/fossil_energy_consumption",
|
||||
|
||||
+40
-20
@@ -11,7 +11,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.solar_production",
|
||||
state: "88.6",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Solar",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -20,7 +20,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.battery_input",
|
||||
state: "4",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Battery Input",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -29,7 +29,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.battery_output",
|
||||
state: "3",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Battery Output",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -38,7 +38,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_consumption_tarif_1",
|
||||
state: "88.6",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Grid consumption low tariff",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -47,7 +47,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_consumption_tarif_2",
|
||||
state: "88.6",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Grid consumption high tariff",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -56,7 +56,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_production_tarif_1",
|
||||
state: "88.6",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Returned to grid low tariff",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -65,7 +65,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_production_tarif_2",
|
||||
state: "88.6",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Returned to grid high tariff",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -74,7 +74,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_consumption_tarif_1_cost",
|
||||
state: "2",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
unit_of_measurement: "EUR",
|
||||
},
|
||||
},
|
||||
@@ -82,7 +82,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_consumption_tarif_2_cost",
|
||||
state: "2",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
unit_of_measurement: "EUR",
|
||||
},
|
||||
},
|
||||
@@ -90,7 +90,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_production_tarif_1_compensation",
|
||||
state: "2",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
unit_of_measurement: "EUR",
|
||||
},
|
||||
},
|
||||
@@ -98,7 +98,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_production_tarif_2_compensation",
|
||||
state: "2",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
unit_of_measurement: "EUR",
|
||||
},
|
||||
},
|
||||
@@ -138,7 +138,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_gas_cost",
|
||||
state: "2",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
unit_of_measurement: "EUR",
|
||||
},
|
||||
},
|
||||
@@ -146,7 +146,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_gas",
|
||||
state: "4",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Gas",
|
||||
unit_of_measurement: "m³",
|
||||
},
|
||||
@@ -155,16 +155,36 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_water",
|
||||
state: "4000",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Water",
|
||||
unit_of_measurement: "L",
|
||||
},
|
||||
},
|
||||
"sensor.gas_kitchen": {
|
||||
entity_id: "sensor.gas_kitchen",
|
||||
state: "100",
|
||||
attributes: {
|
||||
friendly_name: "Gas kitchen",
|
||||
unit_of_measurement: "m³",
|
||||
device_class: "gas",
|
||||
state_class: "total_increasing",
|
||||
},
|
||||
},
|
||||
"sensor.gas_heat_pump": {
|
||||
entity_id: "sensor.gas_heat_pump",
|
||||
state: "500",
|
||||
attributes: {
|
||||
friendly_name: "Gas heat pump",
|
||||
unit_of_measurement: "m³",
|
||||
device_class: "gas",
|
||||
state_class: "total_increasing",
|
||||
},
|
||||
},
|
||||
"sensor.energy_car": {
|
||||
entity_id: "sensor.energy_car",
|
||||
state: "4",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Electric car",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -173,7 +193,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_ac",
|
||||
state: "3",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Air conditioning",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -182,7 +202,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_washing_machine",
|
||||
state: "6",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Washing machine",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -191,7 +211,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_dryer",
|
||||
state: "5.5",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Dryer",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -200,7 +220,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_heat_pump",
|
||||
state: "6",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Heat pump",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
@@ -209,7 +229,7 @@ export const energyEntities = () =>
|
||||
entity_id: "sensor.energy_boiler",
|
||||
state: "7",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
state_class: "total_increasing",
|
||||
friendly_name: "Boiler",
|
||||
unit_of_measurement: "kWh",
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
let entries: EntityRegistryEntry[] = [];
|
||||
|
||||
const newEntry = (
|
||||
export const newEntry = (
|
||||
entry: Pick<EntityRegistryEntry, "entity_id" | "platform" | "unique_id">
|
||||
): EntityRegistryEntry => {
|
||||
const now = Date.now() / 1000;
|
||||
@@ -43,7 +43,9 @@ const displayFields = (entry: EntityRegistryEntry) => ({
|
||||
hidden: entry.hidden_by !== null,
|
||||
});
|
||||
|
||||
const extEntry = (entry: EntityRegistryEntry): ExtEntityRegistryEntry => ({
|
||||
export const extEntry = (
|
||||
entry: EntityRegistryEntry
|
||||
): ExtEntityRegistryEntry => ({
|
||||
...entry,
|
||||
capabilities: {},
|
||||
aliases: [],
|
||||
@@ -56,6 +58,16 @@ export const mockEntityRegistry = (
|
||||
entries = [...data];
|
||||
// A new array, so subscribers see the registry changed after an update
|
||||
hass.mockWS("config/entity_registry/list", () => [...entries]);
|
||||
hass.mockWS(
|
||||
"config/entity_registry/get",
|
||||
(msg: { entity_id: string }): ExtEntityRegistryEntry => {
|
||||
const entry = entries.find((e) => e.entity_id === msg.entity_id);
|
||||
if (!entry) {
|
||||
throw new Error(`Entity not found: ${msg.entity_id}`);
|
||||
}
|
||||
return extEntry(entry);
|
||||
}
|
||||
);
|
||||
hass.mockWS(
|
||||
"config/entity_registry/get_entries",
|
||||
(msg: { entity_ids: string[] }) => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { ensureArray } from "../../../src/common/array/ensure-array";
|
||||
import type {
|
||||
TargetExplanation,
|
||||
HassServiceTargetWithExclude,
|
||||
TargetItem,
|
||||
TargetType,
|
||||
} from "../../../src/data/target";
|
||||
|
||||
const TYPES: TargetType[] = ["entity", "device", "area", "floor", "label"];
|
||||
|
||||
const items = (target?: HassServiceTarget): TargetItem[] =>
|
||||
TYPES.flatMap((type) => {
|
||||
const ids = target?.[`${type}_id`];
|
||||
return ids ? ensureArray(ids).map((id) => ({ type, id: String(id) })) : [];
|
||||
});
|
||||
|
||||
/** What one target item resolves to, and the areas and devices it reaches. */
|
||||
export interface ResolvedTargetItem {
|
||||
areas: string[];
|
||||
devices: string[];
|
||||
entities: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Like core's explain_target: resolves each item of the target and of its
|
||||
* `exclude` on its own with `resolve`, and tells what each exclude takes out
|
||||
* of each included item. Like core, `entity_id: all` is every entity in
|
||||
* `entityIds`, and excludes resolve every entity, not only the primary ones.
|
||||
*/
|
||||
export const explainTarget = (
|
||||
target: HassServiceTargetWithExclude,
|
||||
resolve: (
|
||||
target: HassServiceTarget,
|
||||
primaryEntitiesOnly: boolean
|
||||
) => ResolvedTargetItem,
|
||||
entityIds: string[],
|
||||
primaryEntitiesOnly = true
|
||||
): TargetExplanation => {
|
||||
const single = ({ type, id }: TargetItem): HassServiceTarget => ({
|
||||
[`${type}_id`]: [id],
|
||||
});
|
||||
const includes = items(target).map((item) => {
|
||||
if (item.type === "entity" && item.id === "all") {
|
||||
return {
|
||||
...item,
|
||||
entities: entityIds,
|
||||
referenced_areas: [],
|
||||
referenced_devices: [],
|
||||
};
|
||||
}
|
||||
const { areas, devices, entities } = resolve(
|
||||
single(item),
|
||||
primaryEntitiesOnly
|
||||
);
|
||||
return {
|
||||
...item,
|
||||
entities,
|
||||
referenced_areas: areas,
|
||||
referenced_devices: devices,
|
||||
};
|
||||
});
|
||||
return {
|
||||
includes,
|
||||
excludes: items(target.exclude).map((item) => {
|
||||
const excluded = new Set(resolve(single(item), false).entities);
|
||||
return {
|
||||
...item,
|
||||
removed: includes
|
||||
.map(({ type, id, entities }) => ({
|
||||
type,
|
||||
id,
|
||||
entities: entities.filter((entityId) => excluded.has(entityId)),
|
||||
}))
|
||||
.filter((include) => include.entities.length > 0),
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { LogbookStreamMessage } from "../../../src/data/logbook";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
/**
|
||||
* Answers logbook subscriptions with an empty logbook. No entries come from
|
||||
* an automation or script trace.
|
||||
*/
|
||||
export const mockLogbook = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("logbook/event_stream", (_msg, _currentHass, onChange) => {
|
||||
onChange?.({ events: [] } satisfies LogbookStreamMessage);
|
||||
|
||||
return () => undefined;
|
||||
});
|
||||
hass.mockWS("trace/contexts", () => ({}));
|
||||
};
|
||||
@@ -149,6 +149,7 @@ const repositories: RepositoryBase[] = [
|
||||
const repositoryInfo = (repository: RepositoryBase): RepositoryInfo => ({
|
||||
...repository,
|
||||
additional_info: `# ${repository.name}\n\n${repository.description}\n\nThis is a demo. Installing is not available.`,
|
||||
beta: false,
|
||||
default_branch: "main",
|
||||
issues: 12,
|
||||
releases: [repository.available_version],
|
||||
|
||||
@@ -158,6 +158,24 @@ const statisticsFunctions: Record<
|
||||
(bucketStart, bucketEnd) =>
|
||||
(20 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
|
||||
),
|
||||
"sensor.gas_kitchen": (_id, start, end, period = "hour") =>
|
||||
generateSumStatistics(
|
||||
start,
|
||||
end,
|
||||
period,
|
||||
100,
|
||||
(bucketStart, bucketEnd) =>
|
||||
(0.1 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
|
||||
),
|
||||
"sensor.gas_heat_pump": (_id, start, end, period = "hour") =>
|
||||
generateSumStatistics(
|
||||
start,
|
||||
end,
|
||||
period,
|
||||
500,
|
||||
(bucketStart, bucketEnd) =>
|
||||
(0.6 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
|
||||
),
|
||||
"sensor.energy_consumption_tarif_1": (
|
||||
_id: string,
|
||||
start: Date,
|
||||
@@ -372,7 +390,9 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
|
||||
const entityState = hass.states[id];
|
||||
const state = entityState ? Number(entityState.state) : 1;
|
||||
statistics[id] =
|
||||
entityState && "last_reset" in entityState.attributes
|
||||
entityState &&
|
||||
(entityState.attributes.state_class === "total" ||
|
||||
entityState.attributes.state_class === "total_increasing")
|
||||
? generateSumStatistics(
|
||||
start,
|
||||
end,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { ensureArray } from "../../../src/common/array/ensure-array";
|
||||
import type { ExtractFromTargetResult } from "../../../src/data/target";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { getLabelIds } from "./label_registry";
|
||||
|
||||
// Expands a target to its areas, devices and entities, like core does
|
||||
export const expandTarget = (
|
||||
hass: MockHomeAssistant,
|
||||
target: HassServiceTarget,
|
||||
primaryEntitiesOnly = true
|
||||
) => {
|
||||
const missing = {
|
||||
floors: [] as string[],
|
||||
areas: [] as string[],
|
||||
devices: [] as string[],
|
||||
labels: [] as string[],
|
||||
};
|
||||
const knownLabelIds = getLabelIds();
|
||||
const floorIds = ensureArray(target.floor_id ?? []);
|
||||
const labelIds = ensureArray(target.label_id ?? []);
|
||||
missing.floors.push(...floorIds.filter((id) => !(id in hass.floors)));
|
||||
// Like core, unknown labels are reported, but still expanded
|
||||
missing.labels.push(...labelIds.filter((id) => !knownLabelIds.includes(id)));
|
||||
const hasLabel = (labels?: string[]) =>
|
||||
labelIds.some((labelId) => labels?.includes(labelId));
|
||||
|
||||
// Unknown areas and devices are reported, but still referenced
|
||||
const areaIds = new Set(ensureArray(target.area_id ?? []));
|
||||
missing.areas.push(...[...areaIds].filter((id) => !(id in hass.areas)));
|
||||
for (const area of Object.values(hass.areas)) {
|
||||
if (
|
||||
(area.floor_id && floorIds.includes(area.floor_id)) ||
|
||||
hasLabel(area.labels)
|
||||
) {
|
||||
areaIds.add(area.area_id);
|
||||
}
|
||||
}
|
||||
|
||||
// A targeted or labeled device includes its child devices
|
||||
const deviceIds = new Set<string>();
|
||||
const addDevice = (deviceId: string) => {
|
||||
deviceIds.add(deviceId);
|
||||
for (const device of Object.values(hass.devices)) {
|
||||
if (device.parent_device_id === deviceId) {
|
||||
deviceIds.add(device.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const deviceId of ensureArray(target.device_id ?? [])) {
|
||||
if (!(deviceId in hass.devices)) {
|
||||
missing.devices.push(deviceId);
|
||||
}
|
||||
addDevice(deviceId);
|
||||
}
|
||||
for (const device of Object.values(hass.devices)) {
|
||||
if (hasLabel(device.labels)) {
|
||||
addDevice(device.id);
|
||||
}
|
||||
}
|
||||
|
||||
// A child device without its own area is in the area of its parent device
|
||||
const deviceArea = (deviceId: string) => {
|
||||
const device = hass.devices[deviceId];
|
||||
return (
|
||||
device?.area_id ??
|
||||
(device?.parent_device_id
|
||||
? hass.devices[device.parent_device_id]?.area_id
|
||||
: undefined)
|
||||
);
|
||||
};
|
||||
const areaDeviceIds = new Set<string>();
|
||||
for (const device of Object.values(hass.devices)) {
|
||||
const areaId = deviceArea(device.id);
|
||||
if (areaId && areaIds.has(areaId)) {
|
||||
areaDeviceIds.add(device.id);
|
||||
}
|
||||
}
|
||||
|
||||
const entityIds = new Set(ensureArray(target.entity_id ?? []));
|
||||
for (const entity of Object.values(hass.entities)) {
|
||||
// The display registry keeps the entities of a previous demo, and like
|
||||
// core, hidden entities are never included indirectly
|
||||
if (!(entity.entity_id in hass.states) || entity.hidden) {
|
||||
continue;
|
||||
}
|
||||
const primary = !primaryEntitiesOnly || !entity.entity_category;
|
||||
if (
|
||||
hasLabel(entity.labels) ||
|
||||
(primary &&
|
||||
((entity.device_id && deviceIds.has(entity.device_id)) ||
|
||||
(entity.area_id && areaIds.has(entity.area_id)) ||
|
||||
// An entity with its own area is not in the area of its device
|
||||
(!entity.area_id &&
|
||||
entity.device_id &&
|
||||
areaDeviceIds.has(entity.device_id))))
|
||||
) {
|
||||
entityIds.add(entity.entity_id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
areas: [...areaIds],
|
||||
devices: [...new Set([...deviceIds, ...areaDeviceIds])],
|
||||
entities: [...entityIds],
|
||||
missing,
|
||||
};
|
||||
};
|
||||
|
||||
export const mockExtractFromTarget = (hass: MockHomeAssistant) =>
|
||||
hass.mockWS(
|
||||
"extract_from_target",
|
||||
(
|
||||
msg: { target: HassServiceTarget; primary_entities_only?: boolean },
|
||||
currentHass: MockHomeAssistant
|
||||
): ExtractFromTargetResult => {
|
||||
const { areas, devices, entities, missing } = expandTarget(
|
||||
currentHass,
|
||||
msg.target,
|
||||
msg.primary_entities_only ?? true
|
||||
);
|
||||
return {
|
||||
missing_areas: missing.areas,
|
||||
missing_devices: missing.devices,
|
||||
missing_floors: missing.floors,
|
||||
missing_labels: missing.labels,
|
||||
referenced_areas: areas,
|
||||
referenced_devices: devices,
|
||||
referenced_entities: entities,
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,133 @@
|
||||
import type {
|
||||
ForecastAttribute,
|
||||
ModernForecastType,
|
||||
} from "../../../src/data/weather";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const HOUR = 3600000;
|
||||
|
||||
const CONDITIONS = [
|
||||
"partlycloudy",
|
||||
"rainy",
|
||||
"cloudy",
|
||||
"sunny",
|
||||
"pouring",
|
||||
"partlycloudy",
|
||||
"sunny",
|
||||
];
|
||||
|
||||
// Daily changes around the current temperature, so the forecast looks real.
|
||||
const TEMPERATURE_CHANGES = [0, -1.5, -3, -1, 1.5, 2.5, 1];
|
||||
|
||||
const PRECIPITATION = new Map([
|
||||
["rainy", 2.4],
|
||||
["pouring", 9.1],
|
||||
]);
|
||||
|
||||
const forecastEntry = (
|
||||
datetime: Date,
|
||||
temperature: number,
|
||||
condition: string
|
||||
): ForecastAttribute => ({
|
||||
datetime: datetime.toISOString(),
|
||||
condition,
|
||||
temperature: Math.round(temperature * 10) / 10,
|
||||
precipitation: PRECIPITATION.get(condition) ?? 0,
|
||||
precipitation_probability: PRECIPITATION.has(condition) ? 80 : 10,
|
||||
});
|
||||
|
||||
const generateForecast = (
|
||||
forecastType: ModernForecastType,
|
||||
temperature: number,
|
||||
condition: string
|
||||
): ForecastAttribute[] => {
|
||||
const conditions = [condition, ...CONDITIONS.slice(1)];
|
||||
const now = new Date();
|
||||
|
||||
if (forecastType === "hourly") {
|
||||
now.setMinutes(0, 0, 0);
|
||||
|
||||
return Array.from({ length: 24 }, (_, hour) => {
|
||||
const datetime = new Date(now.getTime() + (hour + 1) * HOUR);
|
||||
|
||||
// Warmest mid-afternoon and coldest before dawn
|
||||
const change =
|
||||
3 * Math.sin(((datetime.getHours() - 9) / 24) * 2 * Math.PI) -
|
||||
3 * Math.sin(((now.getHours() - 9) / 24) * 2 * Math.PI);
|
||||
|
||||
return forecastEntry(
|
||||
datetime,
|
||||
temperature + change,
|
||||
conditions[Math.floor(hour / 6)]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
now.setHours(12, 0, 0, 0);
|
||||
|
||||
if (forecastType === "twice_daily") {
|
||||
return conditions.flatMap((dayCondition, day) => {
|
||||
const datetime = new Date(now.getTime() + day * 24 * HOUR);
|
||||
const dayTemperature = temperature + TEMPERATURE_CHANGES[day];
|
||||
|
||||
return [
|
||||
{
|
||||
...forecastEntry(datetime, dayTemperature, dayCondition),
|
||||
is_daytime: true,
|
||||
},
|
||||
{
|
||||
...forecastEntry(
|
||||
new Date(datetime.getTime() + 12 * HOUR),
|
||||
dayTemperature - 6,
|
||||
dayCondition
|
||||
),
|
||||
is_daytime: false,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
return conditions.map((dayCondition, day) => {
|
||||
const dayTemperature = temperature + TEMPERATURE_CHANGES[day];
|
||||
|
||||
return {
|
||||
...forecastEntry(
|
||||
new Date(now.getTime() + day * 24 * HOUR),
|
||||
dayTemperature,
|
||||
dayCondition
|
||||
),
|
||||
templow: Math.round((dayTemperature - 6) * 10) / 10,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Answers forecast subscriptions of weather entities. An entity with a
|
||||
* `forecast` attribute gets that forecast. Other entities get a forecast made
|
||||
* up from their condition and temperature.
|
||||
*/
|
||||
export const mockWeather = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"weather/subscribe_forecast",
|
||||
(
|
||||
msg: { entity_id: string; forecast_type: ModernForecastType },
|
||||
currentHass,
|
||||
onChange
|
||||
) => {
|
||||
const stateObj = currentHass.states[msg.entity_id];
|
||||
onChange?.({
|
||||
type: msg.forecast_type,
|
||||
forecast: stateObj
|
||||
? (stateObj.attributes.forecast ??
|
||||
generateForecast(
|
||||
msg.forecast_type,
|
||||
Number(stateObj.attributes.temperature ?? 15),
|
||||
stateObj.state
|
||||
))
|
||||
: null,
|
||||
});
|
||||
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -81,6 +81,10 @@ export default [
|
||||
header: "Feedback and status",
|
||||
pages: ["ha-alert", "ha-spinner", "ha-tip", "ha-bar", "ha-gauge"],
|
||||
},
|
||||
{
|
||||
header: "Charts",
|
||||
pages: ["state-history-charts", "statistics-chart"],
|
||||
},
|
||||
{
|
||||
header: "Labels and text",
|
||||
pages: ["ha-badge", "ha-label-badge", "ha-chips", "ha-marquee-text"],
|
||||
|
||||
@@ -1,68 +1,14 @@
|
||||
import {
|
||||
FAMILY_EVENTS,
|
||||
mockCalendarEvents,
|
||||
WORK_EVENTS,
|
||||
} from "../../../demo/src/stubs/calendar";
|
||||
import { mockEntityRegistry } from "../../../demo/src/stubs/entity_registry";
|
||||
import type { CalendarEventApiData } from "../../../src/data/calendar";
|
||||
import { CalendarEntityFeature } from "../../../src/data/calendar";
|
||||
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
// Sample calendars with events relative to today, so demos always show
|
||||
// something in the current week and month.
|
||||
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
|
||||
const day = (offset: number): Date => {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
date.setDate(date.getDate() + offset);
|
||||
return date;
|
||||
};
|
||||
|
||||
const isoDate = (date: Date) =>
|
||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
|
||||
const at = (offset: number, hours: number, minutes = 0) => {
|
||||
const date = day(offset);
|
||||
date.setHours(hours, minutes);
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
const allDay = (offset: number, days = 1) => ({
|
||||
start: isoDate(day(offset)),
|
||||
end: isoDate(day(offset + days)),
|
||||
all_day: true,
|
||||
});
|
||||
|
||||
const FAMILY_EVENTS: CalendarEventApiData[] = [
|
||||
{ summary: "School run", start: at(0, 8), end: at(0, 8, 30) },
|
||||
{
|
||||
summary: "Dentist",
|
||||
start: at(0, 15, 30),
|
||||
end: at(0, 16, 15),
|
||||
location: "Main Street 12",
|
||||
},
|
||||
{ summary: "Grandma's birthday", ...allDay(1) },
|
||||
{ summary: "Football practice", start: at(2, 17), end: at(2, 18, 30) },
|
||||
{
|
||||
summary: "Dinner with friends",
|
||||
start: at(3, 19),
|
||||
end: at(3, 22),
|
||||
status: "tentative",
|
||||
},
|
||||
{ summary: "Summer cabin", ...allDay(5, 3) },
|
||||
{ summary: "Parent-teacher meeting", start: at(9, 18), end: at(9, 19) },
|
||||
{ summary: "Football practice", start: at(9, 17), end: at(9, 18, 30) },
|
||||
{ summary: "Swimming lessons", start: at(12, 10), end: at(12, 11) },
|
||||
{ summary: "Car service", start: at(-3, 9), end: at(-3, 11) },
|
||||
{ summary: "Book club", start: at(-6, 19), end: at(-6, 21) },
|
||||
];
|
||||
|
||||
const WORK_EVENTS: CalendarEventApiData[] = [
|
||||
{ summary: "Stand-up", start: at(0, 9), end: at(0, 9, 15) },
|
||||
{ summary: "Sprint planning", start: at(2, 10), end: at(2, 12) },
|
||||
{ summary: "Team lunch", start: at(4, 12), end: at(4, 13) },
|
||||
{ summary: "Conference", ...allDay(8, 2) },
|
||||
{ summary: "Quarterly review", start: at(-2, 14), end: at(-2, 15, 30) },
|
||||
];
|
||||
|
||||
const EVENTS: Record<string, CalendarEventApiData[] | null> = {
|
||||
"calendar.family": FAMILY_EVENTS,
|
||||
"calendar.work": WORK_EVENTS,
|
||||
@@ -79,8 +25,8 @@ export const CALENDAR_ENTITIES = [
|
||||
friendly_name: "Family",
|
||||
message: "School run",
|
||||
all_day: false,
|
||||
start_time: at(0, 8),
|
||||
end_time: at(0, 8, 30),
|
||||
start_time: FAMILY_EVENTS[0].start,
|
||||
end_time: FAMILY_EVENTS[0].end,
|
||||
location: "",
|
||||
description: "",
|
||||
supported_features:
|
||||
@@ -96,8 +42,8 @@ export const CALENDAR_ENTITIES = [
|
||||
friendly_name: "Work",
|
||||
message: "Stand-up",
|
||||
all_day: false,
|
||||
start_time: at(0, 9),
|
||||
end_time: at(0, 9, 15),
|
||||
start_time: WORK_EVENTS[0].start,
|
||||
end_time: WORK_EVENTS[0].end,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -145,20 +91,7 @@ export const mockCalendars = (hass: MockHomeAssistant) => {
|
||||
registryEntry("calendar.empty"),
|
||||
registryEntry("calendar.holidays"),
|
||||
]);
|
||||
hass.mockWS("calendar/event/subscribe", (msg, _hass, onChange) => {
|
||||
const start = new Date(msg.start).getTime();
|
||||
const end = new Date(msg.end).getTime();
|
||||
const events = EVENTS[msg.entity_id];
|
||||
onChange?.({
|
||||
events:
|
||||
events?.filter(
|
||||
(event) =>
|
||||
new Date(event.end as string).getTime() > start &&
|
||||
new Date(event.start as string).getTime() < end
|
||||
) ?? null,
|
||||
});
|
||||
return () => undefined;
|
||||
});
|
||||
mockCalendarEvents(hass, (entityId) => EVENTS[entityId]);
|
||||
// Changes made in the event dialogs are accepted but not stored
|
||||
hass.mockWS("calendar/event/create", () => null);
|
||||
hass.mockWS("calendar/event/update", () => null);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactiveElement } from "lit";
|
||||
import { ContextSubscriptionController } from "../../../src/common/decorators/consume";
|
||||
import { uiContext } from "../../../src/data/context";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
// A demo that provides its own hass hides the gallery theme from its children,
|
||||
// so pass the gallery light or dark mode on to it.
|
||||
export const followGalleryTheme = (
|
||||
host: ReactiveElement,
|
||||
hass: MockHomeAssistant
|
||||
) =>
|
||||
new ContextSubscriptionController(host, uiContext, (ui) => {
|
||||
if (ui) {
|
||||
hass.updateHass({
|
||||
themes: ui.themes,
|
||||
selectedTheme: ui.selectedTheme,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -48,6 +48,36 @@ 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",
|
||||
|
||||
@@ -4,7 +4,7 @@ title: "Our story"
|
||||
|
||||
## Open source home automation that puts local control and privacy first
|
||||
|
||||
Home Assistant is a free and open-source software for home automation that is designed to be the central control system for smart home devices with a focus on local control and privacy. It can be accessed via a web-based user interface, via apps for Android and iOS, or using voice commands via a supported virtual assistant like Google Assistant and Amazon Alexa.
|
||||
Home Assistant is a free and open-source software for home automation that is designed to be the central control system for smart home devices with a focus on local control and privacy. It can be accessed via a web-based user interface, via apps for Android and iOS, or using voice commands via a supported virtual assistant like Google Home and Amazon Alexa.
|
||||
|
||||
IoT devices and services are supported by modular support for controlling proprietary ecosystems if they provide public access via an Open API for third-party integrations and protocols like Bluetooth, MQTT, Zigbee, and Z-Wave, After the Home Assistant software application is installed as a computer appliance it will act as a central control system for home automation. Information from all entities it sees can be used and controlled from within scripts trigger automations using scheduling and "blueprint" subroutines, e.g. for controlling lighting, climate, entertainment systems, and appliances.
|
||||
|
||||
@@ -36,6 +36,6 @@ Durability for the Open Home means that devices are designed and built to keep w
|
||||
|
||||
The project was started as a Python application by Paulus Schoutsen in September 2013 and first published publicly on GitHub in November 2013. In July 2017, a managed operating system called Hass.io was initially introduced to make it easier use to use Home Assistant on single-board computers like the Raspberry Pi series. Its bundled "supervisor" management system allowed users to manage, backup, and update the local installation and introduced the option to extend the functionality of the software with add-ons.
|
||||
|
||||
An optional subscription service was introduced in December 2017 for $5/month to solve the complexities associated with secured remote access, as well as linking to Amazon Alexa and Google Assistant. Nabu Casa, Inc. was formed in September 2018 to take over the subscription service. The company's funding is based solely on revenue from the subscription service. It is used to finance the project's infrastructure and to pay for full-time employees contributing to the project.
|
||||
An optional subscription service was introduced in December 2017 for $5/month to solve the complexities associated with secured remote access, as well as linking to Amazon Alexa and Google Home. Nabu Casa, Inc. was formed in September 2018 to take over the subscription service. The company's funding is based solely on revenue from the subscription service. It is used to finance the project's infrastructure and to pay for full-time employees contributing to the project.
|
||||
|
||||
In January 2020, branding was adjusted to make it easier to refer to different parts of the project. The main piece of software was renamed to Home Assistant Core, while the full suite of software with the embedded operating system and bundled "supervisor" management system was renamed to Home Assistant.
|
||||
@@ -50,6 +50,18 @@ 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
|
||||
@@ -79,6 +91,7 @@ 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,7 +1,9 @@
|
||||
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";
|
||||
@@ -19,13 +21,26 @@ 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 = (key: string) => LOCALIZE_KEYS[key] ?? key;
|
||||
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 DEMO_I18N: HomeAssistantInternationalization = {
|
||||
localize,
|
||||
|
||||
@@ -683,8 +683,13 @@ const SCHEMAS: {
|
||||
name: "Unit of measurement",
|
||||
input: {
|
||||
sensor_device_class: {
|
||||
name: "Device class",
|
||||
selector: { device_class: { domain: "sensor" } },
|
||||
name: "Device class of the unit below",
|
||||
selector: {
|
||||
device_class: {
|
||||
domain: "sensor",
|
||||
context: { filter_unit_of_measurement: "unit_linked" },
|
||||
},
|
||||
},
|
||||
},
|
||||
sensor_state_class: {
|
||||
name: "State class",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { customElement, state } from "lit/decorators";
|
||||
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
|
||||
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
|
||||
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
|
||||
import { explainTarget } from "../../../../demo/src/stubs/explain_target";
|
||||
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
|
||||
import { ensureArray } from "../../../../src/common/array/ensure-array";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
@@ -225,6 +226,20 @@ class DemoHaTargetPickerExclusions
|
||||
hass.updateHass({ areas, entities: ENTITY_REGISTRY });
|
||||
hass.mockWS("config/device_registry/list_composite_splits", () => ({}));
|
||||
hass.mockWS("extract_from_target", extractFromTarget);
|
||||
hass.mockWS("explain_target", ({ target }: { target: HassServiceTarget }) =>
|
||||
explainTarget(
|
||||
target,
|
||||
(single) => {
|
||||
const resolved = extractFromTarget({ target: single });
|
||||
return {
|
||||
areas: resolved.referenced_areas,
|
||||
devices: resolved.referenced_devices,
|
||||
entities: resolved.referenced_entities,
|
||||
};
|
||||
},
|
||||
Object.keys(hass.states)
|
||||
)
|
||||
);
|
||||
hass.mockWS("auth/sign_path", (params) => params);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: History charts
|
||||
subtitle: State history as timelines and line charts.
|
||||
---
|
||||
|
||||
`<state-history-charts>` renders the state history of entities: a timeline for entities with text states, and a line chart per unit for numeric ones. It is used by the history panel, the history graph card and the more-info history. The data below is generated, so the charts work without a backend.
|
||||
@@ -0,0 +1,213 @@
|
||||
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";
|
||||
import { followGalleryTheme } from "../../data/follow-gallery-theme";
|
||||
|
||||
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");
|
||||
followGalleryTheme(this, hass);
|
||||
hass.addEntities(ENTITIES);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
return EXAMPLES.map(
|
||||
(example) => html`
|
||||
<ha-card header=${example.title}>
|
||||
<div class="card-content">
|
||||
<p>${example.description}</p>
|
||||
<state-history-charts
|
||||
.historyData=${example.history}
|
||||
.startTime=${START}
|
||||
.endTime=${END}
|
||||
.showNames=${example.showNames ?? true}
|
||||
.insideLabels=${example.insideLabels ?? false}
|
||||
.syncCharts=${example.syncCharts ?? false}
|
||||
.clickForMoreInfo=${false}
|
||||
></state-history-charts>
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
max-width: 800px;
|
||||
margin: 24px auto;
|
||||
}
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-state-history-charts": DemoStateHistoryCharts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Statistics chart
|
||||
subtitle: Long-term statistics as a bar or line chart.
|
||||
---
|
||||
|
||||
`<statistics-chart>` renders long-term statistics from the recorder. It is used by the statistics graph card, the more-info history and the statistics developer tools. The data below is generated, so the charts work without a backend.
|
||||
@@ -0,0 +1,350 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { ChartSelectMode } from "../../../../src/components/chart/ha-chart-base";
|
||||
import "../../../../src/components/chart/statistics-chart";
|
||||
import "../../../../src/components/ha-button";
|
||||
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 { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import { followGalleryTheme } from "../../data/follow-gallery-theme";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface TimeRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface RangeExample {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
statistics: Statistics;
|
||||
statTypes: StatisticType[];
|
||||
chartType: "line" | "bar";
|
||||
unit: string;
|
||||
selectMode: ChartSelectMode;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
const RANGE_EXAMPLES: RangeExample[] = [
|
||||
{
|
||||
id: "point",
|
||||
title: "Point selection",
|
||||
description:
|
||||
"With select-mode set to point, a click selects the hour that contains it, and everything outside fades. Dragging still zooms.",
|
||||
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
|
||||
statTypes: ["change"],
|
||||
chartType: "bar",
|
||||
unit: "kWh",
|
||||
selectMode: "point",
|
||||
hint: "Click on a bar to select its hour.",
|
||||
},
|
||||
{
|
||||
id: "range-bar",
|
||||
title: "Range selection on bars",
|
||||
description:
|
||||
"With select-mode set to range, dragging selects a time range instead of zooming, so several bars can be selected at once. A click still selects one bar.",
|
||||
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
|
||||
statTypes: ["change"],
|
||||
chartType: "bar",
|
||||
unit: "kWh",
|
||||
selectMode: "range",
|
||||
hint: "Click or drag on the chart to select one or more bars.",
|
||||
},
|
||||
{
|
||||
id: "range",
|
||||
title: "Range selection on lines",
|
||||
description: "The same on a line chart.",
|
||||
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
|
||||
statTypes: ["mean", "min", "max"],
|
||||
chartType: "line",
|
||||
unit: "°C",
|
||||
selectMode: "range",
|
||||
hint: "Click or drag on the chart to select a period.",
|
||||
},
|
||||
];
|
||||
|
||||
const formatRange = (range: TimeRange) =>
|
||||
`${new Date(range.start).toLocaleString()} to ${new Date(range.end).toLocaleString()}`;
|
||||
|
||||
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;
|
||||
|
||||
@state() private _selections: Record<string, TimeRange | undefined> = {};
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
followGalleryTheme(this, hass);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
return html`${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>
|
||||
`
|
||||
)}
|
||||
${RANGE_EXAMPLES.map((example) => this._renderRangeExample(example))}`;
|
||||
}
|
||||
|
||||
private _renderRangeExample(example: RangeExample) {
|
||||
const selection = this._selections[example.id];
|
||||
return 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}
|
||||
.highlight=${selection}
|
||||
.clickForMoreInfo=${false}
|
||||
data-example=${example.id}
|
||||
.selectMode=${example.selectMode}
|
||||
hide-legend
|
||||
@chart-select=${this._rangeSelected}
|
||||
></statistics-chart>
|
||||
<div class="selection">
|
||||
${
|
||||
selection
|
||||
? html`
|
||||
<span>${formatRange(selection)}</span>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
data-example=${example.id}
|
||||
@click=${this._clearSelection}
|
||||
>
|
||||
Clear
|
||||
</ha-button>
|
||||
`
|
||||
: html`<span class="hint">${example.hint}</span>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _rangeSelected(ev: HASSDomEvent<HASSDomEvents["chart-select"]>) {
|
||||
const id = (ev.currentTarget as HTMLElement).dataset.example!;
|
||||
this._selections = {
|
||||
...this._selections,
|
||||
[id]: ev.detail,
|
||||
};
|
||||
}
|
||||
|
||||
private _clearSelection(ev: Event) {
|
||||
const id = (ev.currentTarget as HTMLElement).dataset.example!;
|
||||
this._selections = { ...this._selections, [id]: undefined };
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
max-width: 800px;
|
||||
margin: 24px auto;
|
||||
}
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
.selection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ha-space-2);
|
||||
min-height: 48px;
|
||||
margin-top: var(--ha-space-2);
|
||||
padding: 0 var(--ha-space-2) 0 var(--ha-space-4);
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
background-color: var(--secondary-background-color);
|
||||
}
|
||||
.hint {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-statistics-chart": DemoStatisticsChart;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,13 @@ const CONFIGS = [
|
||||
entity: "media_player.music_playing",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Playing Music (all features)",
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.music_all_features",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Playing Stream",
|
||||
config: {
|
||||
|
||||
@@ -201,6 +201,50 @@ const ENTITIES = [
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.on_off_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Bathroom fan",
|
||||
device_class: "fan",
|
||||
percentage: 100,
|
||||
percentage_step: 100,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.six_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Kitchen fan",
|
||||
device_class: "fan",
|
||||
percentage: 50,
|
||||
percentage_step: 100 / 6,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.ten_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Living room fan",
|
||||
device_class: "fan",
|
||||
percentage: 70,
|
||||
percentage_step: 10,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.eleven_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Office fan",
|
||||
device_class: "fan",
|
||||
percentage: 45,
|
||||
percentage_step: 100 / 11,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const CONFIGS = [
|
||||
@@ -410,6 +454,55 @@ const CONFIGS = [
|
||||
features: [{ type: "fan-speed" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (5 speeds, slider style)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.five_speed_fan",
|
||||
features: [{ type: "fan-speed", style: "slider" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (off/on, slider style falls back to buttons)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.on_off_fan",
|
||||
features: [{ type: "fan-speed", style: "slider" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (6 speeds, slider by default)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.six_speed_fan",
|
||||
features: [{ type: "fan-speed" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (6 speeds, buttons style)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.six_speed_fan",
|
||||
features: [{ type: "fan-speed", style: "buttons" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (10 speeds, buttons style)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.ten_speed_fan",
|
||||
features: [{ type: "fan-speed", style: "buttons" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading:
|
||||
"Fan speed feature (11 speeds, buttons style falls back to slider)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.eleven_speed_fan",
|
||||
features: [{ type: "fan-speed", style: "buttons" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (half width)",
|
||||
config: {
|
||||
@@ -430,6 +523,27 @@ const CONFIGS = [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading:
|
||||
"Fan speed feature (half width, buttons fall back to a slider when they do not fit)",
|
||||
config: {
|
||||
type: "grid",
|
||||
columns: 2,
|
||||
square: false,
|
||||
cards: [
|
||||
{
|
||||
type: "tile",
|
||||
entity: "fan.six_speed_fan",
|
||||
features: [{ type: "fan-speed", style: "buttons" }],
|
||||
},
|
||||
{
|
||||
type: "tile",
|
||||
entity: "fan.ten_speed_fan",
|
||||
features: [{ type: "fan-speed", style: "buttons" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan oscillate feature",
|
||||
config: {
|
||||
|
||||
@@ -121,7 +121,7 @@ const brandEntries: ConfigEntryExtended[] = (
|
||||
["tuya", "Tuya", "setup_error"],
|
||||
["unifi", "UniFi Network", "not_loaded"],
|
||||
["met", "Meteorologisk institutt (Met.no)", "setup_error"],
|
||||
["google_assistant", "Google Assistant", "not_loaded"],
|
||||
["google_assistant", "Google Home", "not_loaded"],
|
||||
["homekit_controller", "HomeKit Device", "setup_retry"],
|
||||
] as [string, string, ConfigEntryExtended["state"]][]
|
||||
).map(([domain, name, entryState]) =>
|
||||
|
||||
+31
-31
@@ -40,7 +40,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "8.0.8",
|
||||
"@babel/runtime": "8.1.0",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.4",
|
||||
"@codemirror/commands": "6.11.1",
|
||||
@@ -54,17 +54,17 @@
|
||||
"@codemirror/view": "6.43.15",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.8.2",
|
||||
"@formatjs/intl-displaynames": "7.3.15",
|
||||
"@formatjs/intl-durationformat": "0.11.1",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.12",
|
||||
"@formatjs/intl-listformat": "8.3.15",
|
||||
"@formatjs/intl-locale": "5.3.12",
|
||||
"@formatjs/intl-numberformat": "9.4.3",
|
||||
"@formatjs/intl-pluralrules": "6.3.16",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.16",
|
||||
"@formatjs/intl-datetimeformat": "8.0.2",
|
||||
"@formatjs/intl-displaynames": "8.0.1",
|
||||
"@formatjs/intl-durationformat": "0.12.3",
|
||||
"@formatjs/intl-getcanonicallocales": "3.3.0",
|
||||
"@formatjs/intl-listformat": "9.0.1",
|
||||
"@formatjs/intl-locale": "5.4.2",
|
||||
"@formatjs/intl-numberformat": "10.0.2",
|
||||
"@formatjs/intl-pluralrules": "7.0.2",
|
||||
"@formatjs/intl-relativetimeformat": "13.0.1",
|
||||
"@home-assistant/webawesome": "3.14.0-ha.2",
|
||||
"@lezer/common": "1.5.4",
|
||||
"@lezer/common": "1.5.5",
|
||||
"@lezer/highlight": "1.2.5",
|
||||
"@lit-labs/motion": "1.1.0",
|
||||
"@lit-labs/observers": "2.1.0",
|
||||
@@ -78,11 +78,11 @@
|
||||
"@mdi/svg": "7.4.47",
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@tsparticles/engine": "4.4.0",
|
||||
"@tsparticles/preset-links": "4.4.0",
|
||||
"@versatiles/style": "7.0.1",
|
||||
"@tsparticles/engine": "4.5.1",
|
||||
"@tsparticles/preset-links": "4.5.1",
|
||||
"@versatiles/style": "8.0.0",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.201.1",
|
||||
"@vvo/tzdb": "6.202.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"barcode-detector": "3.2.3",
|
||||
"cally": "0.9.2",
|
||||
@@ -100,8 +100,8 @@
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fullcalendar": "7.1.3",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.7.3",
|
||||
"home-assistant-js-websocket": "9.8.0",
|
||||
"hls.js": "1.7.4",
|
||||
"home-assistant-js-websocket": "9.8.1",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "12.1.4",
|
||||
"js-yaml": "5.5.0",
|
||||
@@ -110,7 +110,7 @@
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"maplibre-gl": "6.16.0",
|
||||
"marked": "18.1.1",
|
||||
"marked": "18.1.3",
|
||||
"memoize-one": "6.0.0",
|
||||
"nanoid": "6.0.2",
|
||||
"node-vibrant": "4.0.4",
|
||||
@@ -137,14 +137,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ampproject/remapping": "2.3.0",
|
||||
"@babel/core": "8.0.9",
|
||||
"@babel/core": "8.1.0",
|
||||
"@babel/helper-compilation-targets": "8.0.7",
|
||||
"@babel/helper-define-polyfill-provider": "1.0.0",
|
||||
"@babel/plugin-transform-class-properties": "8.0.1",
|
||||
"@babel/plugin-transform-private-methods": "8.0.1",
|
||||
"@babel/plugin-transform-runtime": "8.0.6",
|
||||
"@babel/preset-env": "8.0.7",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.3",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.23.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@html-eslint/eslint-plugin": "0.67.0",
|
||||
@@ -153,8 +153,8 @@
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.64.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.5",
|
||||
"@rspack/core": "2.2.9",
|
||||
"@rspack/dev-server": "2.2.1",
|
||||
"@rspack/core": "2.3.1",
|
||||
"@rspack/dev-server": "2.2.2",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "7.0.1",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
@@ -170,14 +170,14 @@
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@typescript/native": "npm:[email protected]",
|
||||
"@vitest/coverage-v8": "5.0.5",
|
||||
"@vitest/coverage-v8": "5.1.0",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist": "4.29.4",
|
||||
"browserslist": "4.29.5",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"core-js-compat": "3.51.0",
|
||||
"del": "9.0.1",
|
||||
"eslint": "10.13.0",
|
||||
"eslint": "10.14.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
@@ -188,8 +188,8 @@
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.4.1",
|
||||
"generate-license-file": "4.2.5",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.13.0",
|
||||
"glob": "14.0.3",
|
||||
"globals": "17.14.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
@@ -214,16 +214,16 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.72.0",
|
||||
"typescript-eslint": "8.72.1",
|
||||
"vite": "8.3.5",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.0.5",
|
||||
"vitest": "5.1.0",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "7.4.1"
|
||||
},
|
||||
"packageManager": "[email protected]5.0",
|
||||
"packageManager": "[email protected]7.0",
|
||||
"volta": {
|
||||
"node": "24.22.1"
|
||||
"node": "24.22.2"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
diff --git a/modular/sortable.core.esm.js b/modular/sortable.core.esm.js
|
||||
index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa526090a00 100644
|
||||
index cb433ff25353f4836d2ffc813356f945f42acc6b..0a86ad22d05390a5b9ef002905fcd5926852b956 100644
|
||||
--- a/modular/sortable.core.esm.js
|
||||
+++ b/modular/sortable.core.esm.js
|
||||
@@ -1781,11 +1781,16 @@ Sortable.prototype = /** @lends Sortable.prototype */{
|
||||
@@ -1777,11 +1777,16 @@ Sortable.prototype = /** @lends Sortable.prototype */{
|
||||
}
|
||||
if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, !!target) !== false) {
|
||||
capture();
|
||||
@@ -24,7 +24,7 @@ index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa5
|
||||
}
|
||||
parentEl = el; // actualization
|
||||
|
||||
@@ -1802,7 +1807,12 @@ Sortable.prototype = /** @lends Sortable.prototype */{
|
||||
@@ -1798,7 +1803,12 @@ Sortable.prototype = /** @lends Sortable.prototype */{
|
||||
targetRect = getRect(target);
|
||||
if (_onMove(rootEl, el, dragEl, dragRect, target, targetRect, evt, false) !== false) {
|
||||
capture();
|
||||
@@ -38,7 +38,16 @@ index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa5
|
||||
parentEl = el; // actualization
|
||||
|
||||
changed();
|
||||
@@ -1849,10 +1859,15 @@ Sortable.prototype = /** @lends Sortable.prototype */{
|
||||
@@ -1818,7 +1828,7 @@ Sortable.prototype = /** @lends Sortable.prototype */{
|
||||
pastFirstInvertThresh = false;
|
||||
isCircumstantialInvert = !differentRowCol && options.invertSwap || differentLevel;
|
||||
}
|
||||
- direction = _getSwapDirection(evt, target, targetRect, vertical, differentRowCol ? 1 : options.swapThreshold, options.invertedSwapThreshold == null ? options.swapThreshold : options.invertedSwapThreshold, isCircumstantialInvert, lastTarget === target);
|
||||
+ direction = _getSwapDirection(evt, target, targetRect, vertical, differentRowCol ? 1 : options.swapThreshold, options.invertedSwapThreshold == null ? options.swapThreshold : options.invertedSwapThreshold, isCircumstantialInvert, lastTarget === target, _isRtl(el, vertical));
|
||||
var sibling;
|
||||
if (direction !== 0) {
|
||||
// Check if target is beside dragEl in respective direction (ignoring hidden elements)
|
||||
@@ -1845,10 +1855,15 @@ Sortable.prototype = /** @lends Sortable.prototype */{
|
||||
_silent = true;
|
||||
setTimeout(_unsilent, 30);
|
||||
capture();
|
||||
@@ -58,3 +67,53 @@ index 8b5e49b011713c8859c669069fbe85ce53974e1d..6a0afc92787157b8a31c38cc5f67dfa5
|
||||
}
|
||||
|
||||
// Undo chrome's scroll adjustment (has no effect on other browsers)
|
||||
@@ -2242,19 +2257,48 @@ function _disableDraggable(el) {
|
||||
function _unsilent() {
|
||||
_silent = false;
|
||||
}
|
||||
+// Horizontal RTL lists run from right to left. Mirroring the x axis lets the
|
||||
+// checks below keep assuming that the list runs from left to right.
|
||||
+function _isRtl(el, vertical) {
|
||||
+ return !vertical && css(el, 'direction') === 'rtl';
|
||||
+}
|
||||
+function _mirrorX(rect) {
|
||||
+ return {
|
||||
+ left: -rect.right,
|
||||
+ right: -rect.left,
|
||||
+ top: rect.top,
|
||||
+ bottom: rect.bottom,
|
||||
+ width: rect.width,
|
||||
+ height: rect.height
|
||||
+ };
|
||||
+}
|
||||
function _ghostIsFirst(evt, vertical, sortable) {
|
||||
var firstElRect = getRect(getChild(sortable.el, 0, sortable.options, true));
|
||||
var childContainingRect = getChildContainingRectFromElement(sortable.el, sortable.options, ghostEl);
|
||||
+ if (_isRtl(sortable.el, vertical)) {
|
||||
+ evt = { clientX: -evt.clientX, clientY: evt.clientY };
|
||||
+ firstElRect = _mirrorX(firstElRect);
|
||||
+ childContainingRect = _mirrorX(childContainingRect);
|
||||
+ }
|
||||
var spacer = 10;
|
||||
return vertical ? evt.clientX < childContainingRect.left - spacer || evt.clientY < firstElRect.top && evt.clientX < firstElRect.right : evt.clientY < childContainingRect.top - spacer || evt.clientY < firstElRect.bottom && evt.clientX < firstElRect.left;
|
||||
}
|
||||
function _ghostIsLast(evt, vertical, sortable) {
|
||||
var lastElRect = getRect(lastChild(sortable.el, sortable.options.draggable));
|
||||
var childContainingRect = getChildContainingRectFromElement(sortable.el, sortable.options, ghostEl);
|
||||
+ if (_isRtl(sortable.el, vertical)) {
|
||||
+ evt = { clientX: -evt.clientX, clientY: evt.clientY };
|
||||
+ lastElRect = _mirrorX(lastElRect);
|
||||
+ childContainingRect = _mirrorX(childContainingRect);
|
||||
+ }
|
||||
var spacer = 10;
|
||||
return vertical ? evt.clientX > childContainingRect.right + spacer || evt.clientY > lastElRect.bottom && evt.clientX > lastElRect.left : evt.clientY > childContainingRect.bottom + spacer || evt.clientX > lastElRect.right && evt.clientY > lastElRect.top;
|
||||
}
|
||||
-function _getSwapDirection(evt, target, targetRect, vertical, swapThreshold, invertedSwapThreshold, invertSwap, isLastTarget) {
|
||||
+function _getSwapDirection(evt, target, targetRect, vertical, swapThreshold, invertedSwapThreshold, invertSwap, isLastTarget, rtl) {
|
||||
+ if (rtl) {
|
||||
+ evt = { clientX: -evt.clientX, clientY: evt.clientY };
|
||||
+ targetRect = _mirrorX(targetRect);
|
||||
+ }
|
||||
var mouseOnAxis = vertical ? evt.clientY : evt.clientX,
|
||||
targetLength = vertical ? targetRect.height : targetRect.width,
|
||||
targetS1 = vertical ? targetRect.top : targetRect.left,
|
||||
Generated
+1018
-985
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -26,7 +26,7 @@ overrides:
|
||||
lit-html: 3.3.3
|
||||
clean-css: 5.3.3
|
||||
"@lit/reactive-element": 2.1.2
|
||||
tslib: 2.8.1
|
||||
tslib: 2.9.1
|
||||
|
||||
patchedDependencies:
|
||||
"@material/[email protected]": patches/@[email protected]
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="94" height="28" rx="8" fill="white"/>
|
||||
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
|
||||
<rect x="8" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="28" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="48" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="68" y="8" width="18" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 582 B |
@@ -0,0 +1,8 @@
|
||||
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="94" height="28" rx="8" fill="#1C1C1C"/>
|
||||
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="white" stroke-opacity="0.24"/>
|
||||
<rect x="8" y="8" width="18" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="28" y="8" width="18" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="48" y="8" width="18" height="12" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="68" y="8" width="18" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 584 B |
+18
-1
@@ -13,6 +13,9 @@
|
||||
"enabledManagers": ["npm", "nvm", "custom.regex", "github-actions"],
|
||||
"minimumReleaseAge": "3 days",
|
||||
"postUpdateOptions": ["pnpmDedupe"],
|
||||
"constraints": {
|
||||
"ghActionsLock": "v0.4.1"
|
||||
},
|
||||
"lockFileMaintenance": {
|
||||
"description": ["Run after patch releases but before next beta"],
|
||||
"enabled": true,
|
||||
@@ -59,12 +62,26 @@
|
||||
"datasourceTemplate": "github-releases",
|
||||
"extractVersionTemplate": "^v(?<version>.+)$"
|
||||
},
|
||||
{
|
||||
"description": "Keep gh-actions-lock in CI and Renovate's lockfile generator on the same version",
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": [
|
||||
"/^\\.github/workflows/actions-lock\\.yaml$/",
|
||||
"/^renovate\\.json$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"GH_ACTIONS_LOCK_VERSION: (?<currentValue>v\\d+\\.\\d+\\.\\d+)",
|
||||
"\"ghActionsLock\": \"(?<currentValue>v\\d+\\.\\d+\\.\\d+)\""
|
||||
],
|
||||
"depNameTemplate": "github/gh-actions-lock",
|
||||
"datasourceTemplate": "github-releases"
|
||||
},
|
||||
{
|
||||
"description": "Keep Playwright CI container image up to date",
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/workflows/e2e\\.yaml$/"],
|
||||
"matchStrings": [
|
||||
"mcr\\.microsoft\\.com/playwright:(?<currentValue>v\\d+\\.\\d+\\.\\d+-noble)"
|
||||
"mcr\\.microsoft\\.com/playwright:(?<currentValue>v\\d+\\.\\d+\\.\\d+-noble)(?:@(?<currentDigest>sha256:[a-f0-9]{64}))?"
|
||||
],
|
||||
"depNameTemplate": "mcr.microsoft.com/playwright",
|
||||
"datasourceTemplate": "docker",
|
||||
|
||||
@@ -4,8 +4,8 @@ import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import "../components/ha-icon-next";
|
||||
import "../components/ha-list";
|
||||
import "../components/ha-list-item";
|
||||
import "../components/item/ha-list-item-button";
|
||||
import "../components/list/ha-list-base";
|
||||
import type { AuthProvider } from "../data/auth";
|
||||
|
||||
declare global {
|
||||
@@ -32,21 +32,19 @@ export class HaPickAuthProvider extends LitElement {
|
||||
>${this._localize("ui.panel.page-authorize.pick_auth_provider")}</span
|
||||
>
|
||||
</h3>
|
||||
<ha-list>
|
||||
<ha-list-base>
|
||||
${this.authProviders.map(
|
||||
(provider) => html`
|
||||
<ha-list-item
|
||||
hasMeta
|
||||
role="button"
|
||||
<ha-list-item-button
|
||||
.auth_provider=${provider}
|
||||
@click=${this._handlePick}
|
||||
>
|
||||
${provider.name}
|
||||
<ha-icon-next slot="meta"></ha-icon-next>
|
||||
</ha-list-item>
|
||||
<span slot="headline">${provider.name}</span>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-list-item-button>
|
||||
`
|
||||
)}
|
||||
</ha-list>
|
||||
</ha-list-base>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -80,9 +78,9 @@ export class HaPickAuthProvider extends LitElement {
|
||||
background: var(--card-background-color);
|
||||
padding: 0 15px;
|
||||
}
|
||||
ha-list {
|
||||
ha-list-base {
|
||||
margin: 16px -16px 0;
|
||||
--mdc-list-side-padding: 24px;
|
||||
--ha-row-item-padding-inline: 24px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Return a copy of the list with the value in it, without adding duplicates
|
||||
* @param values - The current list
|
||||
* @param value - The value to add
|
||||
*/
|
||||
export const arrayWith = <T>(
|
||||
values: readonly T[] | undefined,
|
||||
value: T
|
||||
): T[] => (values?.includes(value) ? [...values] : [...(values ?? []), value]);
|
||||
|
||||
/**
|
||||
* Return a copy of the list without the value
|
||||
* @param values - The current list
|
||||
* @param value - The value to remove
|
||||
*/
|
||||
export const arrayWithout = <T>(
|
||||
values: readonly T[] | undefined,
|
||||
value: T
|
||||
): T[] => (values ?? []).filter((v) => v !== value);
|
||||
@@ -3,6 +3,12 @@ import { ref } from "lit/directives/ref";
|
||||
|
||||
const MAX_ROWS = 2;
|
||||
|
||||
// A narrow list, as on a phone or a half width card, fits about one item per
|
||||
// row, so two rows hide most of the legend. 450px is about the widest list a
|
||||
// phone viewport gives.
|
||||
const NARROW_MAX_ROWS = 3;
|
||||
const NARROW_WIDTH = 450;
|
||||
|
||||
// How many items fit in maxRows of a wrapping row, leaving room for the
|
||||
// "more" item after them when not all of them fit.
|
||||
export const fitInRows = (
|
||||
@@ -34,9 +40,10 @@ export const fitInRows = (
|
||||
return widths.length;
|
||||
};
|
||||
|
||||
// Fits the items of a wrapping legend list into two rows. The list keeps its
|
||||
// "more" item rendered with the legend-more class, and items past the fit
|
||||
// stay rendered out of the flow, so both can always be measured.
|
||||
// Fits the items of a wrapping legend list into two rows, three when it is
|
||||
// narrow. The list keeps its "more" item rendered with the legend-more class,
|
||||
// and items past the fit stay rendered out of the flow, so both can always be
|
||||
// measured.
|
||||
export class LegendFitController {
|
||||
// Items to show while collapsed, undefined until the list is measured
|
||||
public fit?: number;
|
||||
@@ -118,7 +125,7 @@ export class LegendFitController {
|
||||
more?.getBoundingClientRect().width ?? 0,
|
||||
rowWidth,
|
||||
parseFloat(getComputedStyle(list).columnGap) || 0,
|
||||
MAX_ROWS
|
||||
rowWidth < NARROW_WIDTH ? NARROW_MAX_ROWS : MAX_ROWS
|
||||
);
|
||||
if (fit !== this.fit) {
|
||||
this.fit = fit;
|
||||
|
||||
@@ -144,7 +144,8 @@ export class MediaProgressController implements ReactiveController {
|
||||
return;
|
||||
}
|
||||
slider.valueFormatter = formatMediaTime;
|
||||
if (slider.matches(":state(dragging)")) {
|
||||
// matches(":state(dragging)") throws before Chromium 125.
|
||||
if (slider.customStates.has("dragging")) {
|
||||
return;
|
||||
}
|
||||
slider.value = this.progress ?? 0;
|
||||
|
||||
@@ -26,8 +26,10 @@ export interface QueryParamsControllerOptions {
|
||||
* Called when the page of the host is navigated to while the host is
|
||||
* connected, like a second link to an open page or coming back to it. Read
|
||||
* the URL with `searchParams()` and apply it, then `write()` the state back.
|
||||
* Gets the `popstate` or `location-changed` event, or nothing when the host
|
||||
* is connected again.
|
||||
*/
|
||||
navigated: () => void;
|
||||
navigated: (ev?: Event) => void;
|
||||
/**
|
||||
* The path of the host's page, for a page that can first be connected on
|
||||
* a sub-path it sends on to itself, like an add link. Defaults to the path
|
||||
@@ -159,7 +161,7 @@ export class QueryParamsController<
|
||||
}
|
||||
// Leaving the page gets here too, before the router swaps the page
|
||||
if (this._isActive()) {
|
||||
this._options.navigated();
|
||||
this._options.navigated(ev);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// The attributes that hold the address of a link or an image
|
||||
const URL_ATTRIBUTES = [
|
||||
export const URL_ATTRIBUTES = [
|
||||
["a", "href"],
|
||||
["img", "src"],
|
||||
["source", "src"],
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { computeCssColor } from "../color/compute-color";
|
||||
import { computeDomain } from "./compute_domain";
|
||||
import { stateColorCss } from "./state_color";
|
||||
|
||||
export const ALERT_DOMAINS = [
|
||||
"alarm_control_panel",
|
||||
@@ -8,8 +10,6 @@ export const ALERT_DOMAINS = [
|
||||
"lock",
|
||||
];
|
||||
|
||||
export const DEFAULT_ALERT_COLOR = "red";
|
||||
|
||||
export const computeAlertActiveStates = (entityId: string): string[] => {
|
||||
switch (computeDomain(entityId)) {
|
||||
case "alarm_control_panel":
|
||||
@@ -25,3 +25,16 @@ export const computeAlertActiveStates = (entityId: string): string[] => {
|
||||
|
||||
export const isAlertActive = (stateObj: HassEntity): boolean =>
|
||||
computeAlertActiveStates(stateObj.entity_id).includes(stateObj.state);
|
||||
|
||||
export const computeAlertColor = (
|
||||
stateObj: HassEntity,
|
||||
color: string
|
||||
): string | undefined => {
|
||||
if (color === "none") {
|
||||
return undefined;
|
||||
}
|
||||
if (color === "state") {
|
||||
return stateColorCss(stateObj);
|
||||
}
|
||||
return computeCssColor(color);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "../../data/entity/entity_attributes";
|
||||
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
|
||||
import type { FrontendLocaleData } from "../../data/translation";
|
||||
import { TimeZone } from "../../data/translation";
|
||||
import type { WeatherEntity } from "../../data/weather";
|
||||
import { getWeatherUnit } from "../../data/weather";
|
||||
import type { HomeAssistant, ValuePart } from "../../types";
|
||||
@@ -115,6 +116,24 @@ export const computeAttributeValueToParts = (
|
||||
}
|
||||
}
|
||||
|
||||
// Date only, which is timezone agnostic, so parse and format it as
|
||||
// local time to keep the same calendar day
|
||||
if (isDate(attributeValue)) {
|
||||
const date = new Date(`${attributeValue}T00:00`);
|
||||
if (checkValidDate(date)) {
|
||||
return [
|
||||
{
|
||||
type: "value",
|
||||
value: formatDate(
|
||||
date,
|
||||
{ ...locale, time_zone: TimeZone.local },
|
||||
config
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Value was not a timestamp, so only do date formatting
|
||||
const date = new Date(attributeValue);
|
||||
if (checkValidDate(date)) {
|
||||
|
||||
@@ -20,6 +20,11 @@ export interface EntityFilter {
|
||||
|
||||
export type EntityFilterFunc = (entityId: string) => boolean;
|
||||
|
||||
export type EntityFilterSources = Pick<
|
||||
HomeAssistant,
|
||||
"states" | "entities" | "devices" | "areas" | "floors"
|
||||
>;
|
||||
|
||||
const normalizeFilterArray = <T>(
|
||||
value: T | null | T[] | (T | null)[] | undefined
|
||||
): Set<T | null> | undefined => {
|
||||
@@ -157,3 +162,20 @@ export const findEntities = (
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export const findFilteredEntities = (
|
||||
sources: EntityFilterSources,
|
||||
filters: EntityFilter[]
|
||||
): string[] => {
|
||||
const filterFuncs = filters.map((filter) =>
|
||||
generateEntityFilter(
|
||||
sources.states,
|
||||
sources.entities,
|
||||
sources.devices,
|
||||
sources.areas,
|
||||
sources.floors,
|
||||
filter
|
||||
)
|
||||
);
|
||||
return findEntities(Object.keys(sources.states), filterFuncs);
|
||||
};
|
||||
Loaded 100 of 515 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user