mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-19 12:19:32 +00:00
Compare commits
319
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caa9871132 | ||
|
|
b069a362e4 | ||
|
|
2e5814ad0d | ||
|
|
067aa09222 | ||
|
|
c24f4d29a9 | ||
|
|
7597b5ce77 | ||
|
|
a491966068 | ||
|
|
ff76e4e1a0 | ||
|
|
2ad2c38a41 | ||
|
|
4277060d6c | ||
|
|
284691592e | ||
|
|
736d1f4dec | ||
|
|
6a0a5fb2ef | ||
|
|
a2ad5c4b14 | ||
|
|
95afebf538 | ||
|
|
07f7bd91de | ||
|
|
9ca8f2e98b | ||
|
|
04f46f037d | ||
|
|
a9b25a3180 | ||
|
|
4b18f1233e | ||
|
|
424068c4ca | ||
|
|
a6f4ab4a7a | ||
|
|
cfde8cd14b | ||
|
|
921ac4998a | ||
|
|
a0c7c36403 | ||
|
|
21670e5ae6 | ||
|
|
76e353b36b | ||
|
|
ddb33f7d12 | ||
|
|
85ca758060 | ||
|
|
8401541c87 | ||
|
|
2b1b58d482 | ||
|
|
374864fcc1 | ||
|
|
1bcda9fb80 | ||
|
|
dca32d7065 | ||
|
|
cfc5add6cf | ||
|
|
ae6241859f | ||
|
|
4c190f806d | ||
|
|
6e698f8c7c | ||
|
|
63708e9cba | ||
|
|
f1763068e0 | ||
|
|
2c5970304a | ||
|
|
eef2ad91cd | ||
|
|
f6a9b8e8a0 | ||
|
|
25d8c3cadb | ||
|
|
2034395784 | ||
|
|
2ad11a808d | ||
|
|
14866713ba | ||
|
|
d1365b02da | ||
|
|
707dc96fb1 | ||
|
|
db1a628b57 | ||
|
|
21c059e9fc | ||
|
|
33c734ea8b | ||
|
|
d8751e5b89 | ||
|
|
edb7a42a7e | ||
|
|
65a1f0722f | ||
|
|
c1981b16f2 | ||
|
|
dfc1971ab4 | ||
|
|
768d61e223 | ||
|
|
04af517a22 | ||
|
|
0d74036677 | ||
|
|
7a69aac65a | ||
|
|
4407404206 | ||
|
|
d6ed305a8b | ||
|
|
13ea2643ee | ||
|
|
cbae291079 | ||
|
|
71c061b68b | ||
|
|
26dcbfccfc | ||
|
|
3bb04f417c | ||
|
|
03e5b8148b | ||
|
|
49e6a744e2 | ||
|
|
23c75da8f5 | ||
|
|
fdae33a5ec | ||
|
|
ae7ce92a79 | ||
|
|
c3ed2635e4 | ||
|
|
1f2d5af99f | ||
|
|
1b4ff42821 | ||
|
|
eec2ff9fa5 | ||
|
|
6a9501fa8a | ||
|
|
bb7308a024 | ||
|
|
1c151c65f2 | ||
|
|
439d61966d | ||
|
|
6dea194706 | ||
|
|
31b76346d2 | ||
|
|
87c079829a | ||
|
|
158c14b6ab | ||
|
|
de82c36acb | ||
|
|
32801130ba | ||
|
|
0f09f11ac4 | ||
|
|
6f3c4b427b | ||
|
|
809ebc5095 | ||
|
|
fbd1de0ec7 | ||
|
|
9f50d399a6 | ||
|
|
da632926bb | ||
|
|
0e9b62e7d0 | ||
|
|
2f09fb9e5b | ||
|
|
1a922fc067 | ||
|
|
54e15575d2 | ||
|
|
e9bfc29729 | ||
|
|
743755a59f | ||
|
|
ea1d3fef73 | ||
|
|
8a83331e94 | ||
|
|
b207a486dd | ||
|
|
00b3dfd43a | ||
|
|
8f38778180 | ||
|
|
e9ed4c5c66 | ||
|
|
3ae69b041b | ||
|
|
f7804a92f4 | ||
|
|
fb0dcf29cb | ||
|
|
bf564163bf | ||
|
|
93b04625ac | ||
|
|
13c3155dab | ||
|
|
9435c5494d | ||
|
|
4fbd34ac81 | ||
|
|
70390d7e43 | ||
|
|
43395ab59b | ||
|
|
4247a1439e | ||
|
|
0ecf3a0bb3 | ||
|
|
aea94f557b | ||
|
|
cd6bdc0344 | ||
|
|
844d7bc0e5 | ||
|
|
b4c6035a75 | ||
|
|
f77295fcfa | ||
|
|
951ca9e9f1 | ||
|
|
e7f3a02d73 | ||
|
|
9555f6f4d7 | ||
|
|
d7f39d3804 | ||
|
|
854f28fc26 | ||
|
|
ef587c6b77 | ||
|
|
70a96e47ca | ||
|
|
28e7cb39fb | ||
|
|
49d01d20eb | ||
|
|
941b167c82 | ||
|
|
e908ac15d3 | ||
|
|
20cd4683b0 | ||
|
|
c6c85e862d | ||
|
|
c74b9831af | ||
|
|
eb4b1e8b3d | ||
|
|
3cd56c4b9c | ||
|
|
e11cdc16ec | ||
|
|
a42ba01ca2 | ||
|
|
941dbb5697 | ||
|
|
3d6061e6e0 | ||
|
|
21b07240ab | ||
|
|
4fa8cf6dc7 | ||
|
|
c0ed2e1b59 | ||
|
|
5a96e1829f | ||
|
|
10c91bb9a3 | ||
|
|
d74804b44f | ||
|
|
fbe209cd0b | ||
|
|
a66e13b0b9 | ||
|
|
dca4a8e0a9 | ||
|
|
480182c2b3 | ||
|
|
2d11eba37f | ||
|
|
5821cc6e53 | ||
|
|
ade5d15fee | ||
|
|
b8ea4d12b9 | ||
|
|
fcb7efdec2 | ||
|
|
8dfcbe7a74 | ||
|
|
5509c405ad | ||
|
|
bdd67e89e2 | ||
|
|
a9f1a24860 | ||
|
|
20062433ec | ||
|
|
d3ed92978f | ||
|
|
2ae987504a | ||
|
|
8662da2e8a | ||
|
|
f2c2b9e472 | ||
|
|
47289adbab | ||
|
|
6315f4a9e2 | ||
|
|
6269d09b0d | ||
|
|
98cb371d86 | ||
|
|
308b03e432 | ||
|
|
24e15e76d9 | ||
|
|
77d80bdec4 | ||
|
|
8d0d8160f5 | ||
|
|
463bda3e7c | ||
|
|
dfdaeee75b | ||
|
|
f0a67727a2 | ||
|
|
e47a7c3a45 | ||
|
|
da70ab7268 | ||
|
|
2f15c7640f | ||
|
|
1c8da1329e | ||
|
|
a9c449f502 | ||
|
|
568d842245 | ||
|
|
796795818d | ||
|
|
33c1a3acfd | ||
|
|
9e0f0931ae | ||
|
|
b71c47f2e8 | ||
|
|
6afbc6322d | ||
|
|
bd286f2519 | ||
|
|
fb16219c5b | ||
|
|
4852d3c609 | ||
|
|
26c332d922 | ||
|
|
b829ba9eca | ||
|
|
46c65973a0 | ||
|
|
4bb7908f60 | ||
|
|
bc21992178 | ||
|
|
28f53d9f5a | ||
|
|
d2fa820035 | ||
|
|
0e34aa06c1 | ||
|
|
bb168601b8 | ||
|
|
1823e783ee | ||
|
|
03a815bb2e | ||
|
|
adde734800 | ||
|
|
8598a35c88 | ||
|
|
7008f2ee38 | ||
|
|
0a8a71049b | ||
|
|
79488d0c09 | ||
|
|
d0c09951e6 | ||
|
|
3d57d1b949 | ||
|
|
d02b605916 | ||
|
|
a9c1531e37 | ||
|
|
87166c98dc | ||
|
|
89c333894d | ||
|
|
d994db64cb | ||
|
|
d6a3c6efcc | ||
|
|
ce58026788 | ||
|
|
2e09d1a43b | ||
|
|
45e2392eeb | ||
|
|
b0c5ea6296 | ||
|
|
c0204eeb25 | ||
|
|
59444f72bc | ||
|
|
6856449bd3 | ||
|
|
9fa168e22c | ||
|
|
6d6aaecb39 | ||
|
|
d9f2baeeb4 | ||
|
|
234eb800bb | ||
|
|
40c76b3993 | ||
|
|
7ca12940d9 | ||
|
|
19630745d8 | ||
|
|
69f83e0ddc | ||
|
|
afac5d6e56 | ||
|
|
c40846ada7 | ||
|
|
388c4f856b | ||
|
|
7051fd27f7 | ||
|
|
6fca7a89b6 | ||
|
|
121b3dade7 | ||
|
|
5a5611df6b | ||
|
|
a7c435e1a0 | ||
|
|
8bc7ab0e74 | ||
|
|
5ca2ac68a3 | ||
|
|
eb43b1eab8 | ||
|
|
96684da7a7 | ||
|
|
13e9127bdb | ||
|
|
9a45b6a200 | ||
|
|
a3f5929ddd | ||
|
|
0fed256025 | ||
|
|
ae3b3eefb3 | ||
|
|
1e9d0432b3 | ||
|
|
60027ef4b1 | ||
|
|
5b25a220df | ||
|
|
74c44e7c71 | ||
|
|
7b6561491a | ||
|
|
17e0352ecf | ||
|
|
7b379d587c | ||
|
|
6b32c87702 | ||
|
|
2482b47d0b | ||
|
|
e9507ad201 | ||
|
|
c26790e54d | ||
|
|
7eeca385a8 | ||
|
|
0149516743 | ||
|
|
a270426d98 | ||
|
|
f8d70893a1 | ||
|
|
2cc6112d6f | ||
|
|
bfe09bd2e7 | ||
|
|
d1370c8577 | ||
|
|
430890f401 | ||
|
|
ba536e13e7 | ||
|
|
4278d16a62 | ||
|
|
1c28b7fef1 | ||
|
|
f867b6186f | ||
|
|
7432e63c16 | ||
|
|
0bda4a13ca | ||
|
|
dd0a0df88f | ||
|
|
6775b1e2bb | ||
|
|
06df4046a9 | ||
|
|
634b6ddfcd | ||
|
|
970bb12856 | ||
|
|
c03750e605 | ||
|
|
655e11ac1c | ||
|
|
b25cf13401 | ||
|
|
24c3ab6061 | ||
|
|
2c73378e9d | ||
|
|
d8e09dd120 | ||
|
|
6cdfafcbf9 | ||
|
|
4819cd6d27 | ||
|
|
c5f099dab7 | ||
|
|
2da41b6e59 | ||
|
|
b7bb487208 | ||
|
|
dfaf2a6f72 | ||
|
|
8639ccde06 | ||
|
|
ee67f6b99f | ||
|
|
e569664724 | ||
|
|
c113eb1f9a | ||
|
|
1f98d6ef6b | ||
|
|
e01f5b830f | ||
|
|
b85cd00cc8 | ||
|
|
e63fe4d631 | ||
|
|
d24d9d6051 | ||
|
|
6c882f47bb | ||
|
|
3689442f43 | ||
|
|
d19635b6bd | ||
|
|
6ef7072e08 | ||
|
|
36829e0e05 | ||
|
|
574c89b5fd | ||
|
|
4384336476 | ||
|
|
bc5eb1af2d | ||
|
|
6c81951d88 | ||
|
|
36b31d2c50 | ||
|
|
e98710867f | ||
|
|
b5afb05d7b | ||
|
|
3f882c9a9b | ||
|
|
94c7d6e20a | ||
|
|
8acc456dfd | ||
|
|
d68a113f4c | ||
|
|
95a4ca587b | ||
|
|
f5ac90a1f9 | ||
|
|
6b060d3082 | ||
|
|
2f8bdf4260 | ||
|
|
ee1e27772f |
No files matched your search
@@ -16,7 +16,7 @@ Container components may keep `hass` when they own it and feed providers. Leaf c
|
||||
## Core Files
|
||||
|
||||
- Context definitions: `src/data/context/index.ts`
|
||||
- `consume` decorator and `ContextSubscriptionController`: `src/common/decorators/consume.ts` (use instead of `@lit/context`'s `consume` and `ContextConsumer`, enforced by ESLint)
|
||||
- `consume` decorator, `ContextSubscriptionController`, and `consumeContext` / `ContextController` for reactive controllers: `src/common/decorators/consume.ts` (use instead of `@lit/context`'s `consume` and `ContextConsumer`, enforced by ESLint)
|
||||
- Entity-scoped consume helpers: `src/common/decorators/consume-context-entry.ts`
|
||||
- Transform decorator: `src/common/decorators/transform.ts`
|
||||
- Canonical migration example: `src/panels/lovelace/cards/hui-button-card.ts`
|
||||
@@ -37,6 +37,7 @@ Container components may keep `hass` when they own it and feed providers. Leaf c
|
||||
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callApiRaw`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
|
||||
| `uiContext` | themes, selected theme, panels, sidebar, and UI state |
|
||||
| `narrowViewportContext` | narrow-layout boolean |
|
||||
| `reducedMotionContext` | `prefers-reduced-motion` media query |
|
||||
|
||||
Lazy contexts subscribe on first consumer and tear down after the last consumer: `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, `manifestsContext`, `triggerDescriptionsContext`, and `conditionDescriptionsContext`.
|
||||
|
||||
@@ -50,7 +51,9 @@ Import `consume` from `src/common/decorators/consume`, not from `@lit/context`.
|
||||
- `@state()` plus `@consume` plus `@transform`: rerenders only when the transformed value changes.
|
||||
- `@consume` alone: the field stays current but never triggers a render. Use this for values only read in event handlers or callbacks, such as `apiContext`, or for data read on demand, such as picker item callbacks.
|
||||
|
||||
In controllers or for lazily created subscriptions, use `ContextSubscriptionController` from the same file instead of `@lit/context`'s `ContextConsumer`, which also forces a host update. Its callback must store the value in a reactive field or call `host.requestUpdate()` itself when the host needs to rerender.
|
||||
In reactive controllers, extend `ContextController` and decorate fields with `consumeContext({ context, subscribe, transform })` from the same file. `transform` stores one part of the context value, and `contextUpdated()` runs only when that part changes. `@state()` and `@transform` do not work on controller fields; call `host.requestUpdate()` when the host needs to rerender. Example: `src/data/service-info-controller.ts`.
|
||||
|
||||
For lazily created subscriptions, use `ContextSubscriptionController` from the same file instead of `@lit/context`'s `ContextConsumer`, which also forces a host update. Its callback must store the value in a reactive field or call `host.requestUpdate()` itself when the host needs to rerender.
|
||||
|
||||
Use entity-scoped helpers when the component watches an entity ID held on the host:
|
||||
|
||||
|
||||
@@ -54,6 +54,10 @@ http://localhost:8090/?demo=<second slug>#/energy/water
|
||||
- `demo/src/stubs/`: Mocked WebSocket and REST APIs.
|
||||
- `demo/script/develop_demo`, `demo/script/build_demo`: Development server and static build wrappers.
|
||||
|
||||
## Core Data
|
||||
|
||||
The service descriptions in `src/fake_data/demo_services.ts` and the trigger and condition descriptions and icons in `demo/src/stubs/automation_platforms_*.ts` are generated from Home Assistant Core. Do not edit them by hand. Run `script/gen_demo_core_data` to regenerate them from the core `dev` branch, and add a domain to the lists in `build-scripts/gulp/gen-demo-core-data.js` when the demo needs its services, triggers, or conditions. Their translations come from the backend translations merged into the demo build.
|
||||
|
||||
## Shared Gallery Stubs
|
||||
|
||||
`demo/src/stubs/` is shared, not demo-private: gallery pages import from it directly. Before changing or removing anything a stub does, search for its callers across `demo/src/` and `gallery/src/`, then check the affected gallery pages as well as the demo.
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
# Path rules for .github/workflows/labeler.yaml, which runs actions/labeler on
|
||||
# every pull request. A label is added when a pull request changes a matching
|
||||
# file, and removed again when it no longer does, including one added by hand.
|
||||
#
|
||||
# Do not add these labels here. .github/scripts/label-content.mts applies and
|
||||
# removes them from paths and code. A rule here would remove them whenever its
|
||||
# paths don't match, even when the script's code check does:
|
||||
#
|
||||
# - Companion App
|
||||
# - Home Assistant Link
|
||||
# - Labs
|
||||
# - Supervisor
|
||||
|
||||
Activity:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/logbook/**
|
||||
- src/panels/lovelace/**/*logbook*
|
||||
- src/data/logbook.ts
|
||||
|
||||
Agents:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -16,6 +36,36 @@ Agents:
|
||||
- .github/skills/**
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
|
||||
AI:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/panels/config/core/{ha-config-section-ai,ai-task-pref,mcp-pref}.ts"
|
||||
- src/components/ha-suggest-with-ai-button.ts
|
||||
- "src/data/{ai_task,llm}.ts"
|
||||
|
||||
Areas:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/areas/**
|
||||
- src/data/area/**
|
||||
- "src/data/{area_floor_picker,floor_registry,ws-area_registry,ws-floor_registry}.ts"
|
||||
|
||||
Automations:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/panels/config/{automation,script,scene,blueprint}/**"
|
||||
- src/components/automation/**
|
||||
- src/components/trace/**
|
||||
- src/data/trace*.ts
|
||||
- "src/data/{automation,automation_i18n,script,script_i18n,scene,blueprint,trigger,condition,action}.ts"
|
||||
|
||||
Backups:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/backup/**
|
||||
- src/data/backup*.ts
|
||||
- src/onboarding/onboarding-restore-backup.ts
|
||||
|
||||
Build:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -23,12 +73,37 @@ Build:
|
||||
- .browserslistrc
|
||||
- gulpfile.js
|
||||
|
||||
Calendar:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/calendar/**
|
||||
- src/panels/lovelace/**/*calendar*
|
||||
- src/data/calendar.ts
|
||||
|
||||
Cast:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- cast/src/**
|
||||
- src/cast/**
|
||||
|
||||
Connectivity:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/connectivity/**
|
||||
- src/panels/config/tags/**
|
||||
- "src/panels/config/integrations/integration-panels/{bluetooth,infrared,matter,modbus,mqtt,radio_frequency,serial,thread,zha,zwave_js}/**"
|
||||
- "src/data/{bluetooth,infrared,matter,matter-lock,modbus,mqtt,otbr,radio_frequency,tag,thread,usb,zha,zwave_js,zwave_js-credentials}.ts"
|
||||
|
||||
Dashboards:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/lovelace/**
|
||||
- "src/panels/{home,climate,light,security,maintenance,map}/**"
|
||||
- src/panels/config/lovelace/**
|
||||
- "src/panels/config/dashboard/{dashboard-card,dialog-new-dashboard,show-dialog-new-dashboard}.ts"
|
||||
- src/data/lovelace/**
|
||||
- src/data/lovelace*.ts
|
||||
|
||||
Demo:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -57,6 +132,29 @@ Dependencies:
|
||||
- "^renovate/"
|
||||
- "^dependabot/"
|
||||
|
||||
Devices:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/devices/**
|
||||
- src/data/device/device_registry.ts
|
||||
- src/data/ws-device_registry.ts
|
||||
|
||||
Energy:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/energy/**
|
||||
- src/panels/config/energy/**
|
||||
- src/panels/lovelace/cards/energy/**
|
||||
- src/panels/lovelace/**/*energy*
|
||||
- src/data/energy*.ts
|
||||
|
||||
Entities:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/entities/**
|
||||
- src/data/entity/entity_registry*.ts
|
||||
- src/data/ws-entity_registry_display.ts
|
||||
|
||||
GitHub Actions:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -64,6 +162,100 @@ GitHub Actions:
|
||||
- .github/workflows/**
|
||||
- .github/*.yml
|
||||
|
||||
Helpers:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/helpers/**
|
||||
- src/data/helpers_crud.ts
|
||||
|
||||
History:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/history/**
|
||||
- src/panels/lovelace/**/*history*
|
||||
- src/data/history.ts
|
||||
|
||||
Integrations:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/integrations/**
|
||||
- src/panels/config/application_credentials/**
|
||||
- src/dialogs/config-flow/**
|
||||
- src/data/application_credential.ts
|
||||
- src/data/config_entries.ts
|
||||
- "src/data/{config_flow,data_entry_flow,options_flow,sub_config_flow}.ts"
|
||||
|
||||
Labels:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/labels/**
|
||||
- src/data/label/**
|
||||
|
||||
Landing Page:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- landing-page/**
|
||||
|
||||
Marketplace:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/marketplace/**
|
||||
- src/data/marketplace/**
|
||||
- src/panels/config/integrations/offer-marketplace-uninstall.ts
|
||||
|
||||
Media:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/media-browser/**
|
||||
- src/components/media-player/**
|
||||
- src/data/media_source.ts
|
||||
|
||||
Notifications:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/dialogs/notifications/**
|
||||
- src/data/persistent_notification.ts
|
||||
|
||||
Onboarding:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/onboarding/**
|
||||
- src/auth/**
|
||||
- src/data/onboarding.ts
|
||||
- src/data/auth.ts
|
||||
|
||||
People:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/panels/config/{person,users}/**"
|
||||
- "src/data/{person,user,ws-user}.ts"
|
||||
|
||||
Profile:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/profile/**
|
||||
|
||||
Search:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/dialogs/quick-bar/**
|
||||
- src/state/quick-bar-mixin.ts
|
||||
- "src/data/{quick_bar,search}.ts"
|
||||
|
||||
Sidebar:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/components/ha-sidebar.ts
|
||||
- src/dialogs/sidebar/**
|
||||
|
||||
System:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/panels/config/{core,hardware,info,logs,network,repairs,storage}/**"
|
||||
- src/panels/config/dashboard/ha-config-updates.ts
|
||||
- "src/dialogs/{repairs-flow,restart}/**"
|
||||
- "src/data/{analytics,entity_id_format,error_log,hardware,network,repairs,system_health,system_log,system_log_report,update}.ts"
|
||||
|
||||
"Tests: E2E":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -93,3 +285,37 @@ GitHub Actions:
|
||||
- any-glob-to-any-file:
|
||||
- test/*.ts
|
||||
- "test/!(e2e)/**"
|
||||
|
||||
Theming:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/resources/theme/**
|
||||
- src/state/themes-mixin.ts
|
||||
- src/common/dom/apply_themes_on_element.ts
|
||||
- "src/data/{theme,ws-themes}.ts"
|
||||
|
||||
To-do:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/todo/**
|
||||
- src/panels/lovelace/**/*todo*
|
||||
- src/data/todo.ts
|
||||
|
||||
Tools:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/tools/**
|
||||
|
||||
Voice:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/voice-assistants/**
|
||||
- "src/dialogs/{voice-assistant-setup,voice-command-dialog}/**"
|
||||
- src/components/ha-assist-chat.ts
|
||||
- "src/data/{assist_pipeline,assist_satellite,conversation,stt,tts,wake_word}.ts"
|
||||
|
||||
Zones:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/zone/**
|
||||
- src/data/zone.ts
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Fails the check when a pull request carries a label that blocks merging, and
|
||||
// writes the outcome to the job summary. Invoked from the `check` job in
|
||||
// .github/workflows/blocking-labels.yaml via actions/github-script:
|
||||
//
|
||||
// const { default: checkBlockingLabels } =
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`);
|
||||
// await checkBlockingLabels({ github, context, core });
|
||||
|
||||
export default async function checkBlockingLabels({ context, core }) {
|
||||
const blockingLabels = [
|
||||
"wait for backend",
|
||||
"Needs UX",
|
||||
"Do Not Review",
|
||||
"Blocked",
|
||||
"has-parent",
|
||||
];
|
||||
const prLabels = context.payload.pull_request.labels.map((l) => l.name);
|
||||
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
|
||||
if (found.length > 0) {
|
||||
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
|
||||
await core.summary
|
||||
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
|
||||
.addRaw(message)
|
||||
.write();
|
||||
core.setFailed(message);
|
||||
} else {
|
||||
await core.summary
|
||||
.addHeading(
|
||||
":white_check_mark: Pull Request is clear to merge after review",
|
||||
2
|
||||
)
|
||||
.addRaw(
|
||||
"This Pull Request is not blocked by any labels which prevent it from being merged."
|
||||
)
|
||||
.write();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env node
|
||||
// Checks npm registry metadata for the direct dependencies in package.json:
|
||||
// deprecation, source repository and provenance. On pull requests only newly
|
||||
// added packages are checked, and a deprecated one fails the check. Scheduled
|
||||
// and manual runs report on every direct dependency without failing. Invoked
|
||||
// from the `npm-metadata` job in .github/workflows/dependency-review.yaml via
|
||||
// actions/github-script:
|
||||
//
|
||||
// const { default: checkNpmMetadata } =
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-npm-metadata.mts`);
|
||||
// await checkNpmMetadata({ github, context, core });
|
||||
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import type {
|
||||
Context,
|
||||
Core,
|
||||
GitHubScriptArgs,
|
||||
PullRequestPayload,
|
||||
} from "./github-script.d.ts";
|
||||
import { withRetry } from "./github-retry.mts";
|
||||
|
||||
// Scheduled and manual runs have no pull request
|
||||
type Payload = Partial<PullRequestPayload>;
|
||||
|
||||
type Section =
|
||||
| "dependencies"
|
||||
| "devDependencies"
|
||||
| "optionalDependencies"
|
||||
| "peerDependencies";
|
||||
|
||||
type PackageJson = Partial<Record<Section, Record<string, string>>>;
|
||||
|
||||
interface Dependency {
|
||||
name: string;
|
||||
// The package npm installs, which differs from name for npm: aliases
|
||||
registryName: string;
|
||||
version: string;
|
||||
section: Section;
|
||||
}
|
||||
|
||||
// The fields read from a version document on registry.npmjs.org
|
||||
interface NpmManifest {
|
||||
deprecated?: string;
|
||||
repository?: string | { url?: string };
|
||||
dist?: { attestations?: object };
|
||||
}
|
||||
|
||||
interface Result {
|
||||
name: string;
|
||||
version: string;
|
||||
section: Section;
|
||||
skipped?: string;
|
||||
deprecated?: string;
|
||||
repository?: string;
|
||||
provenance?: boolean;
|
||||
}
|
||||
|
||||
const SECTIONS: Section[] = [
|
||||
"dependencies",
|
||||
"devDependencies",
|
||||
"optionalDependencies",
|
||||
"peerDependencies",
|
||||
];
|
||||
|
||||
const EXACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
|
||||
|
||||
const NPM_ALIAS = /^npm:((?:@[^/]+\/)?[^@]+)@(.+)$/;
|
||||
|
||||
const CONCURRENCY = 8;
|
||||
|
||||
// Returns undefined when the registry reports 404, and throws on any other
|
||||
// failure so a registry outage fails the job instead of skipping the check.
|
||||
const fetchJson = async <T,>(url: string): Promise<T | undefined> => {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 404) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Request to ${url} failed: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
return JSON.parse(await response.text());
|
||||
};
|
||||
|
||||
const readPackageJson = async (
|
||||
github: Octokit,
|
||||
context: Context<Payload>,
|
||||
ref: string
|
||||
): Promise<PackageJson> => {
|
||||
const { data } = await withRetry(`package.json lookup at ${ref}`, () =>
|
||||
github.rest.repos.getContent({
|
||||
...context.repo,
|
||||
path: "package.json",
|
||||
ref,
|
||||
mediaType: { format: "raw" },
|
||||
})
|
||||
);
|
||||
|
||||
// The raw media type returns the file contents as a string, but Octokit
|
||||
// types the response as the JSON form
|
||||
if (typeof data !== "string") {
|
||||
throw new Error("Expected raw package.json contents");
|
||||
}
|
||||
|
||||
return JSON.parse(data);
|
||||
};
|
||||
|
||||
const listDependencies = (pkg: PackageJson): Dependency[] =>
|
||||
SECTIONS.flatMap((section) =>
|
||||
Object.entries(pkg[section] ?? {}).map(([name, spec]) => {
|
||||
// npm: aliases install another package, e.g. npm:[email protected]
|
||||
const alias = spec.match(NPM_ALIAS);
|
||||
|
||||
return {
|
||||
name,
|
||||
registryName: alias?.[1] ?? name,
|
||||
version: alias?.[2] ?? spec,
|
||||
section,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const repositoryUrl = (repository: NpmManifest["repository"]) => {
|
||||
const url = typeof repository === "string" ? repository : repository?.url;
|
||||
|
||||
return url?.replace(/^git\+/, "").replace(/\.git$/, "");
|
||||
};
|
||||
|
||||
const checkPackage = async ({
|
||||
name,
|
||||
registryName,
|
||||
version,
|
||||
section,
|
||||
}: Dependency): Promise<Result> => {
|
||||
const result = {
|
||||
name: registryName === name ? name : `${name} (npm:${registryName})`,
|
||||
version,
|
||||
section,
|
||||
};
|
||||
|
||||
if (!EXACT_VERSION.test(version)) {
|
||||
return { ...result, skipped: "not an exact version" };
|
||||
}
|
||||
|
||||
const manifest = await fetchJson<NpmManifest>(
|
||||
`https://registry.npmjs.org/${encodeURIComponent(registryName)}/${version}`
|
||||
);
|
||||
|
||||
if (!manifest) {
|
||||
return { ...result, skipped: "not found on the npm registry" };
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
deprecated: manifest.deprecated,
|
||||
repository: repositoryUrl(manifest.repository),
|
||||
provenance: Boolean(manifest.dist?.attestations),
|
||||
};
|
||||
};
|
||||
|
||||
const checkAll = async (dependencies: Dependency[]) => {
|
||||
const results: Result[] = [];
|
||||
|
||||
for (let i = 0; i < dependencies.length; i += CONCURRENCY) {
|
||||
results.push(
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
...(await Promise.all(
|
||||
dependencies.slice(i, i + CONCURRENCY).map(checkPackage)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const writeSummary = async (core: Core, heading: string, results: Result[]) => {
|
||||
await core.summary
|
||||
.addHeading(heading, 2)
|
||||
.addTable([
|
||||
[
|
||||
"Package",
|
||||
"Version",
|
||||
"Section",
|
||||
"Deprecated",
|
||||
"Provenance",
|
||||
"Repository",
|
||||
].map((data) => ({ data, header: true })),
|
||||
...results.map((result) =>
|
||||
result.skipped
|
||||
? [
|
||||
result.name,
|
||||
result.version,
|
||||
result.section,
|
||||
`skipped: ${result.skipped}`,
|
||||
"-",
|
||||
"-",
|
||||
]
|
||||
: [
|
||||
result.name,
|
||||
result.version,
|
||||
result.section,
|
||||
result.deprecated ? "yes" : "no",
|
||||
result.provenance ? "yes" : "no",
|
||||
result.repository ?? "none",
|
||||
]
|
||||
),
|
||||
])
|
||||
.write();
|
||||
};
|
||||
|
||||
export default async function checkNpmMetadata({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
}: GitHubScriptArgs<Payload>) {
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
if (!pr) {
|
||||
const pkg = await readPackageJson(github, context, context.sha);
|
||||
const results = await checkAll(listDependencies(pkg));
|
||||
|
||||
const flagged = (result: Result) =>
|
||||
Number(
|
||||
Boolean(result.skipped || result.deprecated || !result.repository)
|
||||
);
|
||||
|
||||
results.sort(
|
||||
(a, b) => flagged(b) - flagged(a) || a.name.localeCompare(b.name)
|
||||
);
|
||||
await writeSummary(core, "Direct dependencies", results);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const [base, head] = await Promise.all([
|
||||
readPackageJson(github, context, pr.base.sha),
|
||||
readPackageJson(github, context, pr.head.sha),
|
||||
]);
|
||||
|
||||
// Compare the packages npm installs, so pointing an npm: alias at another
|
||||
// package counts as new, but moving a package between sections does not
|
||||
const existing = new Set(
|
||||
listDependencies(base).map(({ registryName }) => registryName)
|
||||
);
|
||||
|
||||
const added = listDependencies(head).filter(
|
||||
({ registryName }) => !existing.has(registryName)
|
||||
);
|
||||
|
||||
if (added.length === 0) {
|
||||
core.info("No new dependencies");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await checkAll(added);
|
||||
const annotation = { file: "package.json" };
|
||||
|
||||
for (const result of results) {
|
||||
const label = `${result.name}@${result.version}`;
|
||||
|
||||
if (result.skipped) {
|
||||
core.warning(`${label}: ${result.skipped}, not checked`, annotation);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.deprecated) {
|
||||
core.error(`${label} is deprecated: ${result.deprecated}`, annotation);
|
||||
}
|
||||
|
||||
if (!result.repository) {
|
||||
core.warning(`${label} has no source repository`, annotation);
|
||||
}
|
||||
|
||||
if (!result.provenance) {
|
||||
core.notice(`${label} was published without provenance`, annotation);
|
||||
}
|
||||
}
|
||||
|
||||
await writeSummary(core, "New dependencies", results);
|
||||
|
||||
if (results.some((result) => result.deprecated)) {
|
||||
core.setFailed("New dependencies must not be deprecated on npm");
|
||||
}
|
||||
}
|
||||
+62
-37
@@ -6,14 +6,20 @@
|
||||
// via actions/github-script:
|
||||
//
|
||||
// const { default: checkStandards } =
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`);
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mts`);
|
||||
// await checkStandards({ github, context, core });
|
||||
|
||||
import type {
|
||||
GitHubScriptArgs,
|
||||
PullRequestPayload,
|
||||
} from "./github-script.d.ts";
|
||||
import { withRetry } from "./github-retry.mts";
|
||||
|
||||
export default async function checkPullRequestStandards({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
}) {
|
||||
}: GitHubScriptArgs<PullRequestPayload>) {
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
|
||||
@@ -26,10 +32,12 @@ export default async function checkPullRequestStandards({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await github.rest.orgs.checkMembershipForUser({
|
||||
org: "home-assistant",
|
||||
username: pr.user.login,
|
||||
});
|
||||
await withRetry("organization membership check", () =>
|
||||
github.rest.orgs.checkMembershipForUser({
|
||||
org: "home-assistant",
|
||||
username: pr.user.login,
|
||||
})
|
||||
);
|
||||
core.info(`Skipping organization member: ${pr.user.login}`);
|
||||
return;
|
||||
} catch (_error) {
|
||||
@@ -52,11 +60,16 @@ export default async function checkPullRequestStandards({
|
||||
const normalized = body.toLowerCase();
|
||||
|
||||
// Ignore 404s from mutations that race manual edits or cancelled runs.
|
||||
const ignoreMissing = async (fn) => {
|
||||
const ignoreMissing = async <T,>(fn: () => Promise<T>) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"status" in error &&
|
||||
error.status === 404
|
||||
) {
|
||||
core.info("Target already removed, nothing to do");
|
||||
return;
|
||||
}
|
||||
@@ -65,7 +78,7 @@ export default async function checkPullRequestStandards({
|
||||
};
|
||||
|
||||
// Hide/restore our comment via GraphQL (REST cannot minimize).
|
||||
const setMinimized = async (subjectId, minimized) => {
|
||||
const setMinimized = async (subjectId: string, minimized: boolean) => {
|
||||
const mutation = minimized
|
||||
? `mutation($id: ID!) {
|
||||
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
|
||||
@@ -78,16 +91,18 @@ export default async function checkPullRequestStandards({
|
||||
}
|
||||
}`;
|
||||
try {
|
||||
await github.graphql(mutation, { id: subjectId });
|
||||
await withRetry(`comment ${minimized ? "minimize" : "restore"}`, () =>
|
||||
github.graphql(mutation, { id: subjectId })
|
||||
);
|
||||
} catch (error) {
|
||||
core.info(
|
||||
`Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}`
|
||||
`Could not ${minimized ? "minimize" : "restore"} comment: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Content of a "## <name>" section, or null when the heading is absent.
|
||||
const section = (name) => {
|
||||
const section = (name: string) => {
|
||||
const match = body.match(
|
||||
new RegExp(`##\\s${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
|
||||
);
|
||||
@@ -122,13 +137,16 @@ export default async function checkPullRequestStandards({
|
||||
|
||||
const isValid = problems.length === 0;
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
per_page: 100,
|
||||
});
|
||||
const existing = comments.find((c) => c.body.includes(marker));
|
||||
const comments = await withRetry("comment list", () =>
|
||||
github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
per_page: 100,
|
||||
})
|
||||
);
|
||||
|
||||
const existing = comments.find((c) => c.body?.includes(marker));
|
||||
const hasLabel = pr.labels.some((l) => l.name === label);
|
||||
|
||||
if (isValid) {
|
||||
@@ -136,12 +154,14 @@ export default async function checkPullRequestStandards({
|
||||
|
||||
if (hasLabel) {
|
||||
await ignoreMissing(() =>
|
||||
github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
name: label,
|
||||
})
|
||||
withRetry("label removal", () =>
|
||||
github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
name: label,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
if (existing) {
|
||||
@@ -153,12 +173,14 @@ export default async function checkPullRequestStandards({
|
||||
core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
|
||||
|
||||
if (!hasLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
labels: [label],
|
||||
});
|
||||
await withRetry("label addition", () =>
|
||||
github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
labels: [label],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const message =
|
||||
@@ -174,14 +196,17 @@ export default async function checkPullRequestStandards({
|
||||
`for more on creating a great pull request (see point 6).`;
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body: message,
|
||||
});
|
||||
await withRetry("comment update", () =>
|
||||
github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body: message,
|
||||
})
|
||||
);
|
||||
await setMinimized(existing.node_id, false);
|
||||
} else {
|
||||
// Not retried, as a retry after GitHub created the comment would post it twice
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
+29
-19
@@ -5,29 +5,35 @@
|
||||
// actions/github-script:
|
||||
//
|
||||
// const { default: checkTaskAuthorization } =
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`);
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mts`);
|
||||
// await checkTaskAuthorization({ github, context, core });
|
||||
|
||||
import type { GitHubScriptArgs, IssuePayload } from "./github-script.d.ts";
|
||||
import { withRetry } from "./github-retry.mts";
|
||||
|
||||
export default async function checkTaskAuthorization({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
}) {
|
||||
}: GitHubScriptArgs<IssuePayload>) {
|
||||
const issueAuthor = context.payload.issue.user.login;
|
||||
|
||||
// Check if user is an organization member
|
||||
try {
|
||||
await github.rest.orgs.checkMembershipForUser({
|
||||
org: "home-assistant",
|
||||
username: issueAuthor,
|
||||
});
|
||||
await withRetry("organization membership check", () =>
|
||||
github.rest.orgs.checkMembershipForUser({
|
||||
org: "home-assistant",
|
||||
username: issueAuthor,
|
||||
})
|
||||
);
|
||||
core.info(`✅ ${issueAuthor} is an organization member`);
|
||||
return; // Authorized
|
||||
} catch (_error) {
|
||||
core.info(`❌ ${issueAuthor} is not authorized to create Task issues`);
|
||||
}
|
||||
|
||||
// Close the issue with a comment
|
||||
// Close the issue with a comment. Not retried, as a retry after GitHub
|
||||
// created the comment would post it twice.
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
@@ -41,18 +47,22 @@ export default async function checkTaskAuthorization({
|
||||
`If you believe you should have access to create Task issues, please contact the maintainers.`,
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
state: "closed",
|
||||
});
|
||||
await withRetry("issue close", () =>
|
||||
github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
state: "closed",
|
||||
})
|
||||
);
|
||||
|
||||
// Add a label to indicate this was auto-closed
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ["auto-closed"],
|
||||
});
|
||||
await withRetry("auto-closed label", () =>
|
||||
github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ["auto-closed"],
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Retries GitHub API requests with jittered exponential backoff. Shared by the
|
||||
// workflow scripts in this directory, test/e2e/post-report-comment.mjs and the
|
||||
// fetch-nightly-translations gulp task. Only wrap requests that are safe to
|
||||
// repeat: a create that fails after GitHub has applied it would run twice.
|
||||
//
|
||||
// This replaces @octokit/plugin-retry, which only retries HTTP error statuses
|
||||
// on a fixed 1s, 4s, 9s delay with no jitter or logging. GitHub can also
|
||||
// answer 200 with briefly stale data, such as the nightly translations lookup
|
||||
// returning an old run with no artifact, and the plugin cannot retry that.
|
||||
// github-script also leaves its built-in retries off by default.
|
||||
|
||||
const RETRIES = 3;
|
||||
|
||||
const BASE_DELAY = 1000; // ms
|
||||
|
||||
const MAX_DELAY = 10000; // ms
|
||||
|
||||
const TRANSIENT_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
|
||||
|
||||
// Throw from a request to retry a response GitHub may answer differently on
|
||||
// the next attempt, such as briefly stale data
|
||||
export class RetryableError extends Error {}
|
||||
|
||||
// Octokit reports network failures as status 500
|
||||
const isRetryable = (err: unknown) => {
|
||||
if (err instanceof RetryableError) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!(err instanceof Error) || !("status" in err)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const status = Number(err.status);
|
||||
|
||||
return (
|
||||
TRANSIENT_STATUSES.has(status) ||
|
||||
(status === 403 && /rate limit/i.test(err.message))
|
||||
);
|
||||
};
|
||||
|
||||
export async function withRetry<T>(
|
||||
label: string,
|
||||
request: () => Promise<T>
|
||||
): Promise<T> {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
return await request();
|
||||
} catch (err) {
|
||||
if (attempt > RETRIES || !isRetryable(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const delay =
|
||||
Math.min(BASE_DELAY * 2 ** (attempt - 1), MAX_DELAY) *
|
||||
(0.8 + Math.random() * 0.4);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`Retrying ${label} after ${(delay / 1000).toFixed(1)}s (attempt ${attempt}/${RETRIES}): ${err instanceof Error ? err.message : err}`
|
||||
);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, delay);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// The arguments actions/github-script passes to the scripts in this directory,
|
||||
// limited to the parts they use.
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
|
||||
export type SummaryTableCell = string | { data: string; header?: boolean };
|
||||
|
||||
export interface Summary {
|
||||
addHeading(text: string, level?: number): Summary;
|
||||
addRaw(text: string): Summary;
|
||||
addTable(rows: SummaryTableCell[][]): Summary;
|
||||
write(): Promise<Summary>;
|
||||
}
|
||||
|
||||
export interface AnnotationProperties {
|
||||
file?: string;
|
||||
}
|
||||
|
||||
export interface Core {
|
||||
info(message: string): void;
|
||||
notice(message: string, properties?: AnnotationProperties): void;
|
||||
warning(message: string, properties?: AnnotationProperties): void;
|
||||
error(message: string, properties?: AnnotationProperties): void;
|
||||
setFailed(message: string): void;
|
||||
summary: Summary;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
login: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface PullRequestPayload {
|
||||
pull_request: {
|
||||
number: number;
|
||||
body: string | null;
|
||||
draft: boolean;
|
||||
user: User;
|
||||
labels: { name: string }[];
|
||||
base: { sha: string };
|
||||
head: { sha: string };
|
||||
};
|
||||
}
|
||||
|
||||
export interface IssuePayload {
|
||||
issue: {
|
||||
user: User;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Context<Payload> {
|
||||
sha: string;
|
||||
repo: { owner: string; repo: string };
|
||||
issue: { owner: string; repo: string; number: number };
|
||||
payload: Payload;
|
||||
}
|
||||
|
||||
export interface GitHubScriptArgs<Payload> {
|
||||
github: Octokit;
|
||||
context: Context<Payload>;
|
||||
core: Core;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Builds a section of a workflow run's summary: a heading, a table of rows and
|
||||
* the warnings raised along the way.
|
||||
*
|
||||
* Warnings are also logged as annotations, so a script can report problems
|
||||
* without failing its step.
|
||||
*/
|
||||
|
||||
import type { AnnotationProperties, Core } from "./github-script.d.ts";
|
||||
|
||||
interface SummaryOptions {
|
||||
heading: string;
|
||||
columns: string[];
|
||||
// Shown instead of the table when no rows were added
|
||||
empty: string;
|
||||
}
|
||||
|
||||
export const createSummary = (
|
||||
core: Core,
|
||||
{ heading, columns, empty }: SummaryOptions
|
||||
) => {
|
||||
const rows: string[][] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
return {
|
||||
addRow: (...cells: string[]) => {
|
||||
rows.push(cells);
|
||||
},
|
||||
|
||||
warn: (message: string, properties?: AnnotationProperties) => {
|
||||
core.warning(message, properties);
|
||||
warnings.push(message);
|
||||
},
|
||||
|
||||
write: async () => {
|
||||
core.summary.addHeading(heading, 2);
|
||||
|
||||
if (rows.length > 0) {
|
||||
core.summary.addTable([
|
||||
columns.map((data) => ({ data, header: true })),
|
||||
...rows,
|
||||
]);
|
||||
} else {
|
||||
core.summary.addRaw(`${empty}\n`);
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
core.summary
|
||||
.addHeading("Warnings", 3)
|
||||
.addRaw(`${warnings.map((warning) => `- ${warning}`).join("\n")}\n`);
|
||||
}
|
||||
|
||||
await core.summary.write();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,324 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Applies the Companion App, Home Assistant Link, Labs and Supervisor labels
|
||||
* to pull requests.
|
||||
*
|
||||
* These areas are often changed from code outside their folders, which
|
||||
* actions/labeler can't see, so this script owns them and .github/labeler.yml
|
||||
* leaves them out.
|
||||
*
|
||||
* A label matches when the pull request:
|
||||
* - changes a file in one of the area's paths, or
|
||||
* - adds or removes a line that uses the area's API, such as gating a feature
|
||||
* behind Labs or only showing it on Supervisor installs.
|
||||
*
|
||||
* Like actions/labeler, a label is removed once the pull request no longer
|
||||
* matches, including one added by hand.
|
||||
*
|
||||
* Invoked from the `triage` job in .github/workflows/labeler.yaml.
|
||||
*/
|
||||
|
||||
import type {
|
||||
GitHubScriptArgs,
|
||||
PullRequestPayload,
|
||||
} from "./github-script.d.ts";
|
||||
import { withRetry } from "./github-retry.mts";
|
||||
import { createSummary } from "./github-summary.mts";
|
||||
|
||||
const RULES: { label: string; paths: RegExp[]; pattern: RegExp }[] = [
|
||||
{
|
||||
label: "Companion App",
|
||||
paths: [
|
||||
/^src\/external_app\//,
|
||||
/^src\/data\/external\.ts$/,
|
||||
/^src\/util\/is_ios\.ts$/,
|
||||
],
|
||||
// Reads or messages the companion app the frontend is running in, or
|
||||
// imports its modules. Matches imports of data/external rather than
|
||||
// isExternal, which is also a common local name.
|
||||
pattern:
|
||||
/\bauth\??\.external\b(?:[!?]*\.\w+)*(?:\([^)]*)?|\bfireExternalBusMessage\([^)]*|(?:\bimport\s[^;]*?from\s*|\bimport\(\s*)["'][^"']*\/(?:external_app\/[^"']*|data\/external)["']/g,
|
||||
},
|
||||
{
|
||||
label: "Home Assistant Link",
|
||||
paths: [
|
||||
/^src\/panels\/config\/cloud\//,
|
||||
/^src\/data\/cloud\//,
|
||||
/^src\/data\/cloud\.ts$/,
|
||||
],
|
||||
// Only runs when Home Assistant Link is set up
|
||||
pattern: /isComponentLoaded\([^)]*["']cloud["']/g,
|
||||
},
|
||||
{
|
||||
label: "Labs",
|
||||
paths: [/^src\/panels\/config\/labs\//, /^src\/data\/labs\.ts$/],
|
||||
// Gates a feature behind, or toggles, a Labs preview feature
|
||||
pattern:
|
||||
/\b(?:subscribeLabFeatures?|fetchLabFeatures|labsUpdatePreviewFeature)\([^)]*/g,
|
||||
},
|
||||
{
|
||||
label: "Supervisor",
|
||||
paths: [
|
||||
/^src\/panels\/config\/apps\//,
|
||||
/^src\/panels\/app\//,
|
||||
/^src\/data\/hassio\//,
|
||||
/^src\/data\/supervisor\//,
|
||||
],
|
||||
// Only runs on installs with the Supervisor
|
||||
pattern: /isComponentLoaded\([^)]*["']hassio["']/g,
|
||||
},
|
||||
];
|
||||
|
||||
interface HunkSide {
|
||||
lines: string[];
|
||||
changed: boolean[];
|
||||
}
|
||||
|
||||
interface CodeChange {
|
||||
code: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
const parseHunks = (patch: string) => {
|
||||
const hunks: { before: HunkSide; after: HunkSide }[] = [];
|
||||
|
||||
for (const line of patch.split("\n")) {
|
||||
if (line.startsWith("@@")) {
|
||||
hunks.push({
|
||||
before: { lines: [], changed: [] },
|
||||
after: { lines: [], changed: [] },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const hunk = hunks.at(-1);
|
||||
|
||||
if (!hunk || line.startsWith("\\")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = line.slice(1);
|
||||
|
||||
if (!line.startsWith("+")) {
|
||||
hunk.before.lines.push(text);
|
||||
hunk.before.changed.push(line.startsWith("-"));
|
||||
}
|
||||
|
||||
if (!line.startsWith("-")) {
|
||||
hunk.after.lines.push(text);
|
||||
hunk.after.changed.push(line.startsWith("+"));
|
||||
}
|
||||
}
|
||||
|
||||
return hunks;
|
||||
};
|
||||
|
||||
// Ignores whitespace and trailing commas outside strings, as Prettier changes
|
||||
// them when wrapping
|
||||
const normaliseCode = (code: string) => {
|
||||
const parts = code.split(
|
||||
/("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`)/
|
||||
);
|
||||
|
||||
return parts
|
||||
.map((part, i) => {
|
||||
if (i % 2 === 1) {
|
||||
return part;
|
||||
}
|
||||
|
||||
const stripped = part.replace(/\s+/g, "").replace(/,(?=[}\])])/g, "");
|
||||
|
||||
return i === parts.length - 1 ? stripped.replace(/,$/, "") : stripped;
|
||||
})
|
||||
.join("");
|
||||
};
|
||||
|
||||
// Matches whole calls, which can span lines, that touch a changed line
|
||||
const changedMatches = (side: HunkSide, pattern: RegExp) => {
|
||||
const text = side.lines.join("\n");
|
||||
const matches: string[] = [];
|
||||
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const first = text.slice(0, match.index).split("\n").length - 1;
|
||||
const last = first + match[0].split("\n").length - 1;
|
||||
|
||||
if (side.changed.slice(first, last + 1).some(Boolean)) {
|
||||
matches.push(normaliseCode(match[0]));
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
// Matches that were only moved, reindented or reformatted cancel out in pairs,
|
||||
// returning a file with a match that doesn't
|
||||
const unmatchedChange = (added: CodeChange[], removed: CodeChange[]) => {
|
||||
const remaining = [...removed];
|
||||
|
||||
for (const change of added) {
|
||||
const index = remaining.findIndex(({ code }) => code === change.code);
|
||||
|
||||
if (index === -1) {
|
||||
return change.filename;
|
||||
}
|
||||
|
||||
remaining.splice(index, 1);
|
||||
}
|
||||
|
||||
return remaining[0]?.filename;
|
||||
};
|
||||
|
||||
const describeError = (cause: unknown) =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
const isNotFound = (cause: unknown) =>
|
||||
cause instanceof Error && "status" in cause && cause.status === 404;
|
||||
|
||||
export default async function labelContent({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
}: GitHubScriptArgs<PullRequestPayload>) {
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
const summary = createSummary(core, {
|
||||
heading: "Content labels",
|
||||
columns: ["Label", "Change", "Reason"],
|
||||
empty: "No content labels changed.",
|
||||
});
|
||||
|
||||
if (process.env.LABELER_OUTCOME === "failure") {
|
||||
summary.warn(
|
||||
"Applying labels from .github/labeler.yml failed, see its step"
|
||||
);
|
||||
}
|
||||
|
||||
// Bot pull requests, such as Prettier bumps, rewrite code they don't change
|
||||
const checkCode = pr.user.type !== "Bot";
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
let files;
|
||||
let existing: Set<string>;
|
||||
|
||||
try {
|
||||
// Read the current labels, as the event's snapshot can be stale by now
|
||||
const [fileList, labels] = await Promise.all([
|
||||
withRetry("pull request files", () =>
|
||||
github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
})
|
||||
),
|
||||
withRetry("pull request labels", () =>
|
||||
github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
per_page: 100,
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
files = fileList;
|
||||
existing = new Set(labels.map((l) => l.name));
|
||||
} catch (error) {
|
||||
summary.warn(
|
||||
`Could not read the pull request's files and labels: ${describeError(error)}`
|
||||
);
|
||||
await summary.write();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const add: { label: string; reason: string }[] = [];
|
||||
const remove: string[] = [];
|
||||
|
||||
for (const rule of RULES) {
|
||||
const added: CodeChange[] = [];
|
||||
const removed: CodeChange[] = [];
|
||||
|
||||
if (checkCode) {
|
||||
for (const { filename, patch } of files) {
|
||||
for (const { before, after } of parseHunks(patch ?? "")) {
|
||||
for (const code of changedMatches(after, rule.pattern)) {
|
||||
added.push({ code, filename });
|
||||
}
|
||||
|
||||
for (const code of changedMatches(before, rule.pattern)) {
|
||||
removed.push({ code, filename });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Renames count for both locations, as moving a file out of an area changes it
|
||||
const match =
|
||||
files.find(({ filename, previous_filename }) =>
|
||||
rule.paths.some(
|
||||
(path) =>
|
||||
path.test(filename) ||
|
||||
(previous_filename !== undefined && path.test(previous_filename))
|
||||
)
|
||||
)?.filename ?? unmatchedChange(added, removed);
|
||||
|
||||
if (match && !existing.has(rule.label)) {
|
||||
core.info(`Adding ${rule.label} for ${match}`);
|
||||
add.push({ label: rule.label, reason: match });
|
||||
} else if (!match && existing.has(rule.label)) {
|
||||
core.info(`Removing ${rule.label}, no longer matched`);
|
||||
remove.push(rule.label);
|
||||
}
|
||||
}
|
||||
|
||||
const removals = await Promise.allSettled(
|
||||
remove.map((name) =>
|
||||
withRetry("label removal", () =>
|
||||
github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
name,
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
removals.forEach((removal, i) => {
|
||||
const name = remove[i];
|
||||
|
||||
// Already removed, such as by hand or a run that raced this one
|
||||
if (removal.status === "fulfilled" || isNotFound(removal.reason)) {
|
||||
summary.addRow(name, "Removed", "No longer matched");
|
||||
} else {
|
||||
summary.warn(
|
||||
`Could not remove ${name}: ${describeError(removal.reason)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (add.length > 0) {
|
||||
try {
|
||||
await withRetry("label addition", () =>
|
||||
github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
labels: add.map(({ label }) => label),
|
||||
})
|
||||
);
|
||||
|
||||
for (const { label, reason } of add) {
|
||||
summary.addRow(label, "Added", reason);
|
||||
}
|
||||
} catch (error) {
|
||||
summary.warn(
|
||||
`Could not add ${add.map(({ label }) => label).join(", ")}: ${describeError(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await summary.write();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"lib": ["ES2024"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
// actions/github-script imports these directly, relying on Node's type stripping
|
||||
"erasableSyntaxOnly": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["*.mts", "*.d.ts"]
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
name: Blocking labels
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- labeled
|
||||
- unlabeled
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check for labels which block the Pull Request from being merged
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Check for blocking labels
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { default: checkBlockingLabels } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`
|
||||
);
|
||||
await checkBlockingLabels({ github, context, core });
|
||||
@@ -100,12 +100,9 @@ jobs:
|
||||
run: pnpm run test
|
||||
build:
|
||||
name: Build frontend
|
||||
# Runs alongside lint and test rather than after them: the build only needs
|
||||
# the dependency tree, and with the rspack cache it is no longer expensive
|
||||
# enough to be worth serialising behind the other checks. The
|
||||
# cancel-on-failure job below stops the run as soon as a check fails, so a
|
||||
# broken pull request does not finish building.
|
||||
needs: prepare-dependencies
|
||||
needs:
|
||||
- lint
|
||||
- test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
@@ -151,54 +148,3 @@ jobs:
|
||||
path: hass_frontend/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# Now that the checks run in parallel, a failing lint or test no longer stops
|
||||
# the build from finishing on its own, so this watches them and cancels the
|
||||
# whole run on the first failure.
|
||||
#
|
||||
# It is a separate job on purpose. Cancelling needs `actions: write`, and the
|
||||
# other jobs check out the pull request and run its build scripts — handing
|
||||
# them that scope would give PR-controlled code (or a compromised dependency)
|
||||
# write access to Actions. This job never checks out the repository, so the
|
||||
# elevated token stays away from PR code. It also cannot be a job that
|
||||
# `needs` the checks: that would only start once they have all finished, which
|
||||
# is exactly too late to cancel anything.
|
||||
cancel-on-failure:
|
||||
name: Cancel run on failure
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Cancel the run when a check fails
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
watched='^(Lint and check format|Run tests|Build frontend)$'
|
||||
while :; do
|
||||
jobs=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
|
||||
--paginate --jq '.jobs[] | [.name, .status, (.conclusion // "")] | @tsv' \
|
||||
2>/dev/null || true)
|
||||
|
||||
failed=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
|
||||
'$1 ~ w && ($3 == "failure" || $3 == "timed_out") { print $1 }')
|
||||
if [ -n "$failed" ]; then
|
||||
echo "Cancelling the run, these checks failed:"
|
||||
printf '%s\n' "$failed"
|
||||
gh run cancel "$RUN_ID" --repo "$REPO" || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
found=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" '$1 ~ w' | wc -l)
|
||||
running=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
|
||||
'$1 ~ w && $2 != "completed" { print $1 }')
|
||||
if [ "$found" -ge 3 ] && [ -z "$running" ]; then
|
||||
echo "All checks finished without failure"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 15
|
||||
done
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Dependency review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
paths:
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
schedule:
|
||||
- cron: "0 6 1 * *" # Monthly report on all direct dependencies
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
name: Review dependency changes
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To compare the dependency graph between base and head
|
||||
steps:
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
with:
|
||||
license-check: false # Covered by pnpm lint:licenses
|
||||
fail-on-severity: high
|
||||
show-openssf-scorecard: true
|
||||
warn-on-openssf-scorecard-level: 3
|
||||
|
||||
npm-metadata:
|
||||
name: Check npm metadata
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To read package.json
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Check npm metadata
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { default: checkNpmMetadata } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-npm-metadata.mts`
|
||||
);
|
||||
await checkNpmMetadata({ github, context, core });
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
name: Prepare container dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.63.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
options: --user 1001
|
||||
defaults:
|
||||
run:
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.63.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.63.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -261,7 +261,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.63.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
name: "Pull Request Labeler"
|
||||
|
||||
on: pull_request_target # zizmor: ignore[dangerous-triggers] -- safe: only runs actions/labeler, no PR code checkout
|
||||
on: pull_request_target # zizmor: ignore[dangerous-triggers] -- safe: checks out base repo scripts only and reads PR files through the API, never PR head code
|
||||
|
||||
# Runs one at a time per pull request, so an older run can't undo a newer one
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
@@ -10,6 +15,24 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply labels
|
||||
id: labeler
|
||||
continue-on-error: true
|
||||
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||
with:
|
||||
sync-labels: true
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Apply content labels
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
LABELER_OUTCOME: ${{ steps.labeler.outcome }}
|
||||
with:
|
||||
script: |
|
||||
const { default: labelContent } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/label-content.mts`
|
||||
);
|
||||
await labelContent({ github, context, core });
|
||||
@@ -33,6 +33,6 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const { default: checkStandards } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mts`
|
||||
);
|
||||
await checkStandards({ github, context, core });
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send bundle stats and build information to RelativeCI
|
||||
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
|
||||
uses: relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60 # v3.2.4
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY_frontend_modern }}
|
||||
token: ${{ github.token }}
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send bundle stats and build information to RelativeCI
|
||||
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
|
||||
uses: relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60 # v3.2.4
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY_frontend_legacy }}
|
||||
token: ${{ github.token }}
|
||||
|
||||
@@ -19,6 +19,6 @@ jobs:
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
|
||||
- uses: release-drafter/release-drafter@72967cdc98ddd3160f1fc3dff619de365e137dd0 # v7.9.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@58bff37c8947f690ace498be413a9b78d6f30f93 # master
|
||||
uses: home-assistant/actions/helpers/verify-version@06749dd8c0b54f350bc69c8752456cee498808a3 # master
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
@@ -15,22 +15,33 @@ jobs:
|
||||
name: Add no-stale label
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To check out workflow scripts
|
||||
issues: write # To add labels to issues
|
||||
if: >-
|
||||
github.event.issue.type.name == 'Task'
|
||||
|| github.event.issue.type.name == 'Epic'
|
||||
|| github.event.issue.type.name == 'Opportunity'
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Add no-stale label
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['no-stale']
|
||||
});
|
||||
const { withRetry } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/github-retry.mts`
|
||||
);
|
||||
await withRetry("no-stale label", () =>
|
||||
github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['no-stale']
|
||||
})
|
||||
);
|
||||
|
||||
check-authorization:
|
||||
name: Check authorization
|
||||
@@ -51,6 +62,6 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const { default: checkTaskAuthorization } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mts`
|
||||
);
|
||||
await checkTaskAuthorization({ github, context, core });
|
||||
@@ -15,11 +15,12 @@ jobs:
|
||||
if: github.repository == 'home-assistant/frontend'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 90 days stale policy
|
||||
- name: Stale policy
|
||||
uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 90
|
||||
days-before-issue-stale: 90
|
||||
days-before-pr-stale: 60
|
||||
days-before-close: 7
|
||||
operations-per-run: 25
|
||||
remove-stale-when-updated: true
|
||||
|
||||
@@ -14,6 +14,7 @@ dist/
|
||||
# Node
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
.pnpm-store/
|
||||
|
||||
# Python stuff
|
||||
*.py[cod]
|
||||
|
||||
+169
-162
@@ -1,194 +1,201 @@
|
||||
Apache License
|
||||
==============
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
_Version 2.0, January 2004_
|
||||
_<<http://www.apache.org/licenses/>>_
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
### Terms and Conditions for use, reproduction, and distribution
|
||||
1. Definitions.
|
||||
|
||||
#### 1. Definitions
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
“License” shall mean the terms and conditions for use, reproduction, and
|
||||
distribution as defined by Sections 1 through 9 of this document.
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
“Licensor” shall mean the copyright owner or entity authorized by the copyright
|
||||
owner that is granting the License.
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
“Legal Entity” shall mean the union of the acting entity and all other entities
|
||||
that control, are controlled by, or are under common control with that entity.
|
||||
For the purposes of this definition, “control” means **(i)** the power, direct or
|
||||
indirect, to cause the direction or management of such entity, whether by
|
||||
contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or **(iii)** beneficial ownership of such entity.
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
“You” (or “Your”) shall mean an individual or Legal Entity exercising
|
||||
permissions granted by this License.
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
“Source” form shall mean the preferred form for making modifications, including
|
||||
but not limited to software source code, documentation source, and configuration
|
||||
files.
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
“Object” form shall mean any form resulting from mechanical transformation or
|
||||
translation of a Source form, including but not limited to compiled object code,
|
||||
generated documentation, and conversions to other media types.
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
“Work” shall mean the work of authorship, whether in Source or Object form, made
|
||||
available under the License, as indicated by a copyright notice that is included
|
||||
in or attached to the work (an example is provided in the Appendix below).
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
“Derivative Works” shall mean any work, whether in Source or Object form, that
|
||||
is based on (or derived from) the Work and for which the editorial revisions,
|
||||
annotations, elaborations, or other modifications represent, as a whole, an
|
||||
original work of authorship. For the purposes of this License, Derivative Works
|
||||
shall not include works that remain separable from, or merely link (or bind by
|
||||
name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
“Contribution” shall mean any work of authorship, including the original version
|
||||
of the Work and any modifications or additions to that Work or Derivative Works
|
||||
thereof, that is intentionally submitted to Licensor for inclusion in the Work
|
||||
by the copyright owner or by an individual or Legal Entity authorized to submit
|
||||
on behalf of the copyright owner. For the purposes of this definition,
|
||||
“submitted” means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems, and
|
||||
issue tracking systems that are managed by, or on behalf of, the Licensor for
|
||||
the purpose of discussing and improving the Work, but excluding communication
|
||||
that is conspicuously marked or otherwise designated in writing by the copyright
|
||||
owner as “Not a Contribution.”
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
“Contributor” shall mean Licensor and any individual or Legal Entity on behalf
|
||||
of whom a Contribution has been received by Licensor and subsequently
|
||||
incorporated within the Work.
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
#### 2. Grant of Copyright License
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
Subject to the terms and conditions of this License, each Contributor hereby
|
||||
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
|
||||
irrevocable copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the Work and such
|
||||
Derivative Works in Source or Object form.
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
#### 3. Grant of Patent License
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
Subject to the terms and conditions of this License, each Contributor hereby
|
||||
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
|
||||
irrevocable (except as stated in this section) patent license to make, have
|
||||
made, use, offer to sell, sell, import, and otherwise transfer the Work, where
|
||||
such license applies only to those patent claims licensable by such Contributor
|
||||
that are necessarily infringed by their Contribution(s) alone or by combination
|
||||
of their Contribution(s) with the Work to which such Contribution(s) was
|
||||
submitted. If You institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work or a
|
||||
Contribution incorporated within the Work constitutes direct or contributory
|
||||
patent infringement, then any patent licenses granted to You under this License
|
||||
for that Work shall terminate as of the date such litigation is filed.
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
#### 4. Redistribution
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
You may reproduce and distribute copies of the Work or Derivative Works thereof
|
||||
in any medium, with or without modifications, and in Source or Object form,
|
||||
provided that You meet the following conditions:
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
* **(a)** You must give any other recipients of the Work or Derivative Works a copy of
|
||||
this License; and
|
||||
* **(b)** You must cause any modified files to carry prominent notices stating that You
|
||||
changed the files; and
|
||||
* **(c)** You must retain, in the Source form of any Derivative Works that You distribute,
|
||||
all copyright, patent, trademark, and attribution notices from the Source form
|
||||
of the Work, excluding those notices that do not pertain to any part of the
|
||||
Derivative Works; and
|
||||
* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any
|
||||
Derivative Works that You distribute must include a readable copy of the
|
||||
attribution notices contained within such NOTICE file, excluding those notices
|
||||
that do not pertain to any part of the Derivative Works, in at least one of the
|
||||
following places: within a NOTICE text file distributed as part of the
|
||||
Derivative Works; within the Source form or documentation, if provided along
|
||||
with the Derivative Works; or, within a display generated by the Derivative
|
||||
Works, if and wherever such third-party notices normally appear. The contents of
|
||||
the NOTICE file are for informational purposes only and do not modify the
|
||||
License. You may add Your own attribution notices within Derivative Works that
|
||||
You distribute, alongside or as an addendum to the NOTICE text from the Work,
|
||||
provided that such additional attribution notices cannot be construed as
|
||||
modifying the License.
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and may provide
|
||||
additional or different license terms and conditions for use, reproduction, or
|
||||
distribution of Your modifications, or for any such Derivative Works as a whole,
|
||||
provided Your use, reproduction, and distribution of the Work otherwise complies
|
||||
with the conditions stated in this License.
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
#### 5. Submission of Contributions
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
Unless You explicitly state otherwise, any Contribution intentionally submitted
|
||||
for inclusion in the Work by You to the Licensor shall be under the terms and
|
||||
conditions of this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify the terms of
|
||||
any separate license agreement you may have executed with Licensor regarding
|
||||
such Contributions.
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
#### 6. Trademarks
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
This License does not grant permission to use the trade names, trademarks,
|
||||
service marks, or product names of the Licensor, except as required for
|
||||
reasonable and customary use in describing the origin of the Work and
|
||||
reproducing the content of the NOTICE file.
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
#### 7. Disclaimer of Warranty
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Unless required by applicable law or agreed to in writing, Licensor provides the
|
||||
Work (and each Contributor provides its Contributions) on an “AS IS” BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
|
||||
including, without limitation, any warranties or conditions of TITLE,
|
||||
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
|
||||
solely responsible for determining the appropriateness of using or
|
||||
redistributing the Work and assume any risks associated with Your exercise of
|
||||
permissions under this License.
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
#### 8. Limitation of Liability
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
In no event and under no legal theory, whether in tort (including negligence),
|
||||
contract, or otherwise, unless required by applicable law (such as deliberate
|
||||
and grossly negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special, incidental,
|
||||
or consequential damages of any character arising as a result of this License or
|
||||
out of the use or inability to use the Work (including but not limited to
|
||||
damages for loss of goodwill, work stoppage, computer failure or malfunction, or
|
||||
any and all other commercial damages or losses), even if such Contributor has
|
||||
been advised of the possibility of such damages.
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
#### 9. Accepting Warranty or Additional Liability
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
While redistributing the Work or Derivative Works thereof, You may choose to
|
||||
offer, and charge a fee for, acceptance of support, warranty, indemnity, or
|
||||
other liability obligations and/or rights consistent with this License. However,
|
||||
in accepting such obligations, You may act only on Your own behalf and on Your
|
||||
sole responsibility, not on behalf of any other Contributor, and only if You
|
||||
agree to indemnify, defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason of your
|
||||
accepting any such warranty or additional liability.
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
_END OF TERMS AND CONDITIONS_
|
||||
|
||||
### APPENDIX: How to apply the Apache License to your work
|
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate
|
||||
notice, with the fields enclosed by brackets `[]` replaced with your own
|
||||
identifying information. (Don't include the brackets!) The text should be
|
||||
enclosed in the appropriate comment syntax for the file format. We also
|
||||
recommend that a file or class name and description of purpose be included on
|
||||
the same “printed page” as the copyright notice for easier identification within
|
||||
third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
|
||||
"frontend-modern": {
|
||||
"app": 593517,
|
||||
"core": 54884,
|
||||
"authorize": 557490,
|
||||
"onboarding": 676131
|
||||
"app": 624240,
|
||||
"core": 55144,
|
||||
"authorize": 594400,
|
||||
"onboarding": 705499
|
||||
},
|
||||
"frontend-legacy": {
|
||||
"app": 738388,
|
||||
"core": 188119,
|
||||
"authorize": 716748,
|
||||
"onboarding": 840680
|
||||
"app": 776696,
|
||||
"core": 188309,
|
||||
"authorize": 763316,
|
||||
"onboarding": 879807
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// @ts-check
|
||||
|
||||
import { noUnusedElementImport } from "./no-unused-element-import.mjs";
|
||||
|
||||
export default {
|
||||
meta: { name: "eslint-plugin-ha" },
|
||||
rules: {
|
||||
"no-unused-element-import": noUnusedElementImport,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
// @ts-check
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const TAG_PATTERN =
|
||||
/@customElement\(\s*["'`]([a-z0-9_-]+)["'`]|customElements\.define\(\s*["'`]([a-z0-9_-]+)["'`]/g;
|
||||
|
||||
const EXTENSIONS = /\.(ts|js)$/;
|
||||
|
||||
// Entry points register the root element that the served HTML renders.
|
||||
const ENTRY_POINT = /(^|[\\/])(entrypoints[\\/]|entrypoint\.(ts|js)$)/;
|
||||
|
||||
// These resolve a tag from config at runtime, so they register every candidate
|
||||
// they may be asked for.
|
||||
const RUNTIME_REGISTRY =
|
||||
/[\\/]create-element[\\/]|createLovelaceElement\(|getLovelaceElementClass\(/;
|
||||
|
||||
/** @type {Map<string, string[]>} */
|
||||
const tagCache = new Map();
|
||||
|
||||
/** Tags the module at `file` registers when imported. */
|
||||
const registeredTags = (file) => {
|
||||
let tags = tagCache.get(file);
|
||||
if (!tags) {
|
||||
try {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
tags = [...source.matchAll(TAG_PATTERN)].map((m) => m[1] || m[2]);
|
||||
} catch {
|
||||
tags = [];
|
||||
}
|
||||
tagCache.set(file, tags);
|
||||
}
|
||||
return tags;
|
||||
};
|
||||
|
||||
const isFile = (candidate) => {
|
||||
try {
|
||||
return fs.statSync(candidate).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveModule = (fromFile, request) => {
|
||||
const base = path.resolve(path.dirname(fromFile), request);
|
||||
return [
|
||||
base,
|
||||
`${base}.ts`,
|
||||
`${base}.js`,
|
||||
base.replace(/\.js$/, ".ts"),
|
||||
path.join(base, "index.ts"),
|
||||
path.join(base, "index.js"),
|
||||
].find((candidate) => EXTENSIONS.test(candidate) && isFile(candidate));
|
||||
};
|
||||
|
||||
// `hui-${type}-card` or "state-card-" + domain never spell the tag out.
|
||||
const buildsTagAtRuntime = (source, tag) => {
|
||||
const segments = tag.split("-");
|
||||
for (let end = 1; end < segments.length; end++) {
|
||||
const prefix = `${segments.slice(0, end).join("-")}-`;
|
||||
if (
|
||||
source.includes(`\`${prefix}\${`) ||
|
||||
source.includes(`"${prefix}" +`) ||
|
||||
source.includes(`'${prefix}' +`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Plain substring matching, so <ha-icon-button> counts as a use of ha-icon.
|
||||
// That direction is deliberate: it misses dead imports rather than asking for
|
||||
// a removal that breaks registration.
|
||||
const usesTag = (body, tag) => body.includes(tag);
|
||||
|
||||
// A gallery page registers the elements its sibling markdown demos render, so
|
||||
// that markdown is part of what the module is used by.
|
||||
const siblingMarkup = (file) => {
|
||||
const markdown = file.replace(EXTENSIONS, ".markdown");
|
||||
try {
|
||||
return fs.readFileSync(markdown, "utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
/** Blanks out the given ranges so an import path cannot count as a usage. */
|
||||
const withoutRanges = (source, ranges) => {
|
||||
let result = source;
|
||||
for (const [start, end] of ranges) {
|
||||
result =
|
||||
result.slice(0, start) + " ".repeat(end - start) + result.slice(end);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Side-effect imports register a custom element. ESLint's unused-import rules
|
||||
* cannot judge them — there is no binding to be unused — so a registration
|
||||
* outlives the template that needed it and nothing reports the dead import.
|
||||
*
|
||||
* Deliberately not fixable: removing the import is only safe once every file
|
||||
* that renders the element imports it itself, which this rule cannot see.
|
||||
*
|
||||
* Off in eslint.config.mjs while a backlog of those paired cases remains;
|
||||
* `pnpm lint:element-imports` enables it as a warning to list them. Flip it to
|
||||
* "error" there once the list is empty.
|
||||
*
|
||||
* No disable comments while it is off: ESLint reports them as unused
|
||||
* directives, which fails lint:eslint. Imports that must stay get theirs in
|
||||
* the change that turns the rule on.
|
||||
*/
|
||||
export const noUnusedElementImport = {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow side-effect imports of a custom element the importing file never uses",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
unused:
|
||||
'"{{request}}" only registers <{{tags}}>, which this file never uses. Remove the import, adding it to whichever file renders the element if that file relies on this registration.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.filename;
|
||||
if (!EXTENSIONS.test(filename) || ENTRY_POINT.test(filename)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
Program(program) {
|
||||
const imports = program.body.filter(
|
||||
(node) => node.type === "ImportDeclaration"
|
||||
);
|
||||
const sideEffectImports = imports.filter(
|
||||
(node) =>
|
||||
node.specifiers.length === 0 &&
|
||||
typeof node.source.value === "string" &&
|
||||
node.source.value.startsWith(".")
|
||||
);
|
||||
if (!sideEffectImports.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = context.sourceCode.getText();
|
||||
if (RUNTIME_REGISTRY.test(filename) || RUNTIME_REGISTRY.test(source)) {
|
||||
return;
|
||||
}
|
||||
// Every import declaration, not just the side-effect ones: a module
|
||||
// path is never a usage, and `import type { HaCard } from "./ha-card"`
|
||||
// would otherwise pass off its own path as one.
|
||||
const body =
|
||||
withoutRanges(
|
||||
source,
|
||||
imports.map((node) => node.range)
|
||||
) + siblingMarkup(filename);
|
||||
|
||||
for (const node of sideEffectImports) {
|
||||
const request = node.source.value;
|
||||
const target = resolveModule(filename, request);
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
// No tags means no registration to judge: styles, polyfills, mocks.
|
||||
const tags = registeredTags(target);
|
||||
if (
|
||||
!tags.length ||
|
||||
tags.some(
|
||||
(tag) => usesTag(body, tag) || buildsTagAtRuntime(body, tag)
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
messageId: "unused",
|
||||
data: { request, tags: tags.join(">, <") },
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import mapStream from "map-stream";
|
||||
import transform from "gulp-json-transform";
|
||||
import { LokaliseApi } from "@lokalise/node-api";
|
||||
import JSZip from "jszip";
|
||||
import { SERVICE_DOMAINS } from "./gen-demo-core-data.js";
|
||||
|
||||
const inDir = "translations";
|
||||
const inDirFrontend = `${inDir}/frontend`;
|
||||
@@ -51,15 +52,34 @@ function convertBackendTranslations(data, _file) {
|
||||
if (!data.component) {
|
||||
return output;
|
||||
}
|
||||
// Keep the entity domains and the domains with automation triggers or
|
||||
// conditions, so the demo can show states and describe automations. Only
|
||||
// keep the services of the domains whose services the demo mocks.
|
||||
Object.keys(data.component).forEach((domain) => {
|
||||
if (!("entity_component" in data.component[domain])) {
|
||||
const {
|
||||
entity_component,
|
||||
services,
|
||||
triggers,
|
||||
conditions,
|
||||
selector,
|
||||
title,
|
||||
} = data.component[domain];
|
||||
if (
|
||||
!entity_component &&
|
||||
!triggers &&
|
||||
!conditions &&
|
||||
!SERVICE_DOMAINS.includes(domain)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
output.component[domain] = { entity_component: {} };
|
||||
Object.keys(data.component[domain].entity_component).forEach((key) => {
|
||||
output.component[domain].entity_component[key] =
|
||||
data.component[domain].entity_component[key];
|
||||
});
|
||||
output.component[domain] = {
|
||||
entity_component,
|
||||
services: SERVICE_DOMAINS.includes(domain) ? services : undefined,
|
||||
triggers,
|
||||
conditions,
|
||||
selector,
|
||||
title,
|
||||
};
|
||||
});
|
||||
return output;
|
||||
}
|
||||
@@ -155,8 +175,9 @@ gulp.task("fetch-lokalise", async function () {
|
||||
fs.mkdir(inDirBackend, { recursive: true }),
|
||||
]);
|
||||
|
||||
// The backend project only provides entity_component translations, which are
|
||||
// merged into the demo, gallery, cast and e2e builds. The shipped app fetches
|
||||
// The backend project only provides the translations the mocked backend
|
||||
// needs (see convertBackendTranslations), which are merged into the demo,
|
||||
// gallery, cast and e2e builds. The shipped app fetches
|
||||
// them live from core, so builds that only produce the app (release, release
|
||||
// landing-page) can skip this second, whole-project export to save time.
|
||||
const projects = Object.entries(lokaliseProjects).filter(
|
||||
|
||||
+76
-46
@@ -1,7 +1,6 @@
|
||||
// Task to download the latest Lokalise translations from the nightly workflow artifacts
|
||||
|
||||
import { createOAuthDeviceAuth } from "@octokit/auth-oauth-device";
|
||||
import { retry } from "@octokit/plugin-retry";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { deleteAsync } from "del";
|
||||
import { mkdir, readFile, writeFile } from "fs/promises";
|
||||
@@ -10,11 +9,20 @@ import jszip from "jszip";
|
||||
import path from "path";
|
||||
import process from "process";
|
||||
import { extract } from "tar";
|
||||
import {
|
||||
RetryableError,
|
||||
withRetry,
|
||||
} from "../../.github/scripts/github-retry.mts";
|
||||
|
||||
type Artifact = Awaited<
|
||||
ReturnType<Octokit["rest"]["actions"]["listArtifactsForRepo"]>
|
||||
>["data"]["artifacts"][number];
|
||||
|
||||
const MAX_AGE = 24; // hours
|
||||
const OWNER = "home-assistant";
|
||||
const REPO = "frontend";
|
||||
const WORKFLOW_NAME = "nightly.yaml";
|
||||
|
||||
const BRANCH = "dev";
|
||||
const ARTIFACT_NAME = "translations";
|
||||
const CLIENT_ID = "Iv1.3914e28cb27834d1";
|
||||
const EXTRACT_DIR = "translations";
|
||||
@@ -36,11 +44,16 @@ gulp.task("fetch-nightly-translations", async function () {
|
||||
|
||||
// Read current translations artifact info if it exists,
|
||||
// and stop if they are not old enough
|
||||
let currentArtifact;
|
||||
let currentArtifact: Artifact | null;
|
||||
try {
|
||||
currentArtifact = JSON.parse(await readFile(ARTIFACT_FILE, "utf-8"));
|
||||
const artifact: Artifact = JSON.parse(
|
||||
await readFile(ARTIFACT_FILE, "utf-8")
|
||||
);
|
||||
|
||||
currentArtifact = artifact;
|
||||
|
||||
const currentAge =
|
||||
(Date.now() - Date.parse(currentArtifact.created_at)) / 3600000;
|
||||
(Date.now() - Date.parse(artifact.created_at ?? "")) / 3600000;
|
||||
if (currentAge < MAX_AGE) {
|
||||
console.log(
|
||||
"Keeping current translations (only %s hours old)",
|
||||
@@ -63,19 +76,19 @@ gulp.task("fetch-nightly-translations", async function () {
|
||||
}
|
||||
console.warn(
|
||||
"Failed to fetch nightly translations, continuing with English only:",
|
||||
err?.message || err
|
||||
err instanceof Error ? err.message : err
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchTranslations(currentArtifact) {
|
||||
async function fetchTranslations(currentArtifact: Artifact | null) {
|
||||
// To store file writing promises
|
||||
const createExtractDir = mkdir(EXTRACT_DIR, { recursive: true });
|
||||
const writings = [];
|
||||
const writings: Promise<void>[] = [];
|
||||
|
||||
// Authenticate to GitHub using GitHub action token if it exists,
|
||||
// otherwise look for a saved user token or generate a new one if none
|
||||
let tokenAuth;
|
||||
let tokenAuth: { token: string };
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
tokenAuth = { token: process.env.GITHUB_TOKEN };
|
||||
} else {
|
||||
@@ -104,69 +117,86 @@ async function fetchTranslations(currentArtifact) {
|
||||
});
|
||||
tokenAuth = await auth({ type: "oauth" });
|
||||
writings.push(
|
||||
createExtractDir.then(
|
||||
createExtractDir.then(() =>
|
||||
writeFile(TOKEN_FILE, JSON.stringify(tokenAuth, null, 2))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate with token and request workflow runs from GitHub
|
||||
console.log("Fetching new translations...");
|
||||
const octokit = new (Octokit.plugin(retry))({
|
||||
|
||||
// Authenticate with token and find the newest translations artifact
|
||||
const octokit = new Octokit({
|
||||
userAgent: "Fetch Nightly Translations",
|
||||
auth: tokenAuth.token,
|
||||
});
|
||||
|
||||
const workflowRunsResponse = await octokit.rest.actions.listWorkflowRuns({
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
workflow_id: WORKFLOW_NAME,
|
||||
status: "success",
|
||||
event: "schedule",
|
||||
per_page: 1,
|
||||
exclude_pull_requests: true,
|
||||
});
|
||||
if (workflowRunsResponse.data.total_count === 0) {
|
||||
throw Error("No successful nightly workflow runs found");
|
||||
}
|
||||
const latestNightlyRun = workflowRunsResponse.data.workflow_runs[0];
|
||||
// Only the nightly workflow uploads this artifact. Requiring a run from this
|
||||
// repository's own branch excludes artifacts uploaded by fork pull requests.
|
||||
const latestArtifact = await withRetry(
|
||||
"translations artifact lookup",
|
||||
async () => {
|
||||
const { data } = await octokit.rest.actions.listArtifactsForRepo({
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
name: ARTIFACT_NAME,
|
||||
per_page: 10,
|
||||
});
|
||||
|
||||
// Stop if current is already the latest, otherwise Find the translations artifact
|
||||
if (currentArtifact?.workflow_run.id === latestNightlyRun.id) {
|
||||
const artifact = data.artifacts.find(
|
||||
({ expired, workflow_run: run }) =>
|
||||
!expired &&
|
||||
run?.head_branch === BRANCH &&
|
||||
run.head_repository_id === run.repository_id
|
||||
);
|
||||
|
||||
if (!artifact) {
|
||||
throw new RetryableError(
|
||||
`No ${ARTIFACT_NAME} artifact found from ${BRANCH}`
|
||||
);
|
||||
}
|
||||
|
||||
return artifact;
|
||||
}
|
||||
);
|
||||
|
||||
console.log(
|
||||
"Latest translations artifact is %s from workflow run %s (%s)",
|
||||
latestArtifact.id,
|
||||
latestArtifact.workflow_run?.id,
|
||||
latestArtifact.created_at
|
||||
);
|
||||
|
||||
// Stop if current is already the latest
|
||||
if (currentArtifact?.id === latestArtifact.id) {
|
||||
console.log("Stopping because current translations are still the latest");
|
||||
return;
|
||||
}
|
||||
const latestArtifact = (
|
||||
await octokit.actions.listWorkflowRunArtifacts({
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
run_id: latestNightlyRun.id,
|
||||
})
|
||||
).data.artifacts.find((artifact) => artifact.name === ARTIFACT_NAME);
|
||||
if (!latestArtifact) {
|
||||
throw Error("Latest nightly workflow run has no translations artifact");
|
||||
}
|
||||
|
||||
// Remove the current translations
|
||||
const deleteCurrent = Promise.all(writings).then(
|
||||
const deleteCurrent = Promise.all(writings).then(() =>
|
||||
deleteAsync([`${EXTRACT_DIR}/*`, `!${ARTIFACT_FILE}`, `!${TOKEN_FILE}`])
|
||||
);
|
||||
|
||||
// Get the download URL and follow the redirect to download (stored as ArrayBuffer)
|
||||
const downloadResponse = await octokit.actions.downloadArtifact({
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
artifact_id: latestArtifact.id,
|
||||
archive_format: "zip",
|
||||
});
|
||||
if (downloadResponse.status !== 200) {
|
||||
const downloadResponse = await withRetry("translations download", () =>
|
||||
octokit.rest.actions.downloadArtifact({
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
artifact_id: latestArtifact.id,
|
||||
archive_format: "zip",
|
||||
})
|
||||
);
|
||||
|
||||
// Octokit types the redirect, but fetch follows it to the archive
|
||||
if ((downloadResponse.status as number) !== 200) {
|
||||
throw Error("Failure downloading translations artifact");
|
||||
}
|
||||
|
||||
// Artifact is a tarball, but GitHub adds it to a zip file
|
||||
console.log("Unpacking downloaded translations...");
|
||||
const zip = await jszip.loadAsync(downloadResponse.data);
|
||||
const zip = await jszip.loadAsync(downloadResponse.data as ArrayBuffer);
|
||||
await deleteCurrent;
|
||||
const extractStream = zip.file(/.*/)[0].nodeStream().pipe(extract());
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -77,10 +77,6 @@ async function copyMapPanel(staticDir) {
|
||||
// Style, glyphs and sprites for the vector base map
|
||||
await ensureMapAssets();
|
||||
fs.copySync(mapAssetsDir, staticPath("map/"));
|
||||
copyFileDir(
|
||||
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
|
||||
staticPath("map/")
|
||||
);
|
||||
// Controls and popups of the native MapLibre engine
|
||||
copyFileDir(npmPath("maplibre-gl/dist/maplibre-gl.css"), staticPath("map/"));
|
||||
}
|
||||
@@ -177,6 +173,7 @@ gulp.task("copy-static-landing-page", async () => {
|
||||
|
||||
copyFonts(paths.landingPage_output_static);
|
||||
copyTranslations(paths.landingPage_output_static);
|
||||
copyLocaleData(paths.landingPage_output_static);
|
||||
});
|
||||
|
||||
gulp.task("copy-static-e2e-test-app", async () => {
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
// Generates the service, trigger and condition descriptions the demo and
|
||||
// gallery serve instead of a backend, from the files in Home Assistant Core.
|
||||
import gulp from "gulp";
|
||||
import { CORE_SCHEMA, load, mergeTag } from "js-yaml";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
import prettier from "prettier";
|
||||
import paths from "../paths.cjs";
|
||||
|
||||
const CORE_URL =
|
||||
process.env.CORE_COMPONENTS_URL ||
|
||||
"https://raw.githubusercontent.com/home-assistant/core/refs/heads/dev/homeassistant/components";
|
||||
|
||||
// Integrations whose services the mocked backend provides
|
||||
export const SERVICE_DOMAINS = [
|
||||
"alarm_control_panel",
|
||||
"automation",
|
||||
"button",
|
||||
"calendar",
|
||||
"camera",
|
||||
"climate",
|
||||
"conversation",
|
||||
"counter",
|
||||
"cover",
|
||||
"device_tracker",
|
||||
"fan",
|
||||
"frontend",
|
||||
"group",
|
||||
"homeassistant",
|
||||
"humidifier",
|
||||
"image_processing",
|
||||
"input_boolean",
|
||||
"input_button",
|
||||
"input_datetime",
|
||||
"input_number",
|
||||
"input_select",
|
||||
"input_text",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
"logger",
|
||||
"media_player",
|
||||
"notify",
|
||||
"number",
|
||||
"persistent_notification",
|
||||
"person",
|
||||
"recorder",
|
||||
"remote",
|
||||
"scene",
|
||||
"schedule",
|
||||
"script",
|
||||
"select",
|
||||
"siren",
|
||||
"switch",
|
||||
"system_log",
|
||||
"text",
|
||||
"timer",
|
||||
"todo",
|
||||
"tts",
|
||||
"update",
|
||||
"vacuum",
|
||||
"valve",
|
||||
"water_heater",
|
||||
"weather",
|
||||
"zone",
|
||||
];
|
||||
|
||||
// Services that return data. Core sets this when it registers a service, it is
|
||||
// not part of services.yaml. The value is whether the response is optional.
|
||||
const RESPONSE_SERVICES = {
|
||||
"calendar.get_events": false,
|
||||
"conversation.process": true,
|
||||
"media_player.browse_media": false,
|
||||
"media_player.search_media": false,
|
||||
"recorder.get_statistics": false,
|
||||
"schedule.get_schedule": false,
|
||||
"todo.get_items": false,
|
||||
"weather.get_forecasts": false,
|
||||
};
|
||||
|
||||
// Services that are still in services.yaml, but that core no longer registers
|
||||
const UNREGISTERED_SERVICES = ["weather.get_forecast"];
|
||||
|
||||
// Integrations that provide the new style triggers and conditions
|
||||
const AUTOMATION_PLATFORM_DOMAINS = [
|
||||
"air_quality",
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"battery",
|
||||
"button",
|
||||
"calendar",
|
||||
"climate",
|
||||
"counter",
|
||||
"cover",
|
||||
"door",
|
||||
"doorbell",
|
||||
"event",
|
||||
"fan",
|
||||
"garage_door",
|
||||
"gate",
|
||||
"humidifier",
|
||||
"humidity",
|
||||
"illuminance",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"moisture",
|
||||
"moon",
|
||||
"motion",
|
||||
"occupancy",
|
||||
"power",
|
||||
"remote",
|
||||
"scene",
|
||||
"schedule",
|
||||
"select",
|
||||
"siren",
|
||||
"sun",
|
||||
"switch",
|
||||
"temperature",
|
||||
"text",
|
||||
"timer",
|
||||
"todo",
|
||||
"update",
|
||||
"vacuum",
|
||||
"valve",
|
||||
"vibration",
|
||||
"water_heater",
|
||||
"window",
|
||||
"zone",
|
||||
];
|
||||
|
||||
// YAML 1.2 with merge keys, which matches how core reads these files
|
||||
const YAML_SCHEMA = CORE_SCHEMA.withTags([mergeTag]);
|
||||
|
||||
const HEADER = `// This file is auto-generated from Home Assistant Core. Do not edit by hand.
|
||||
// Regenerate with \`script/gen_demo_core_data\`.`;
|
||||
|
||||
const fetchCoreFile = async (domain, file) => {
|
||||
const url = `${CORE_URL}/${domain}/${file}`;
|
||||
const response = await fetch(url);
|
||||
if (response.status === 404) {
|
||||
return undefined;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
};
|
||||
|
||||
// Fetches a file of every domain, keyed by domain
|
||||
const fetchCoreFiles = async (domains, file) =>
|
||||
Object.fromEntries(
|
||||
await Promise.all(
|
||||
domains.map(async (domain) => [domain, await fetchCoreFile(domain, file)])
|
||||
)
|
||||
);
|
||||
|
||||
const parseYaml = (content) => load(content, { schema: YAML_SCHEMA }) ?? {};
|
||||
|
||||
// Enum references look like "cover.CoverEntityFeature.OPEN"
|
||||
const ENUM_REFERENCE = /^([a-z_]+)\.[A-Z]\w*\.[A-Z0-9_]+$/;
|
||||
|
||||
const enumDomains = (value, domains = new Set()) => {
|
||||
if (typeof value === "string") {
|
||||
const match = value.match(ENUM_REFERENCE);
|
||||
if (match) {
|
||||
domains.add(match[1]);
|
||||
}
|
||||
} else if (value && typeof value === "object") {
|
||||
Object.values(value).forEach((val) => enumDomains(val, domains));
|
||||
}
|
||||
return domains;
|
||||
};
|
||||
|
||||
// Resolves "<domain>.<Enum>.<MEMBER>" to the value of the member, like core
|
||||
const enumResolver = async (yamls) => {
|
||||
const domains = [...enumDomains(yamls)];
|
||||
const [consts, inits] = await Promise.all([
|
||||
fetchCoreFiles(domains, "const.py"),
|
||||
fetchCoreFiles(domains, "__init__.py"),
|
||||
]);
|
||||
return (reference) => {
|
||||
const [domain, enumName, member] = reference.split(".");
|
||||
for (const source of [consts[domain], inits[domain]]) {
|
||||
const match = source
|
||||
?.split(`class ${enumName}(`)[1]
|
||||
?.match(new RegExp(`\\n\\s+${member} = ("[^"]*"|\\d+)`));
|
||||
if (match) {
|
||||
return JSON.parse(match[1]);
|
||||
}
|
||||
}
|
||||
throw new Error(`Unknown enum ${reference}`);
|
||||
};
|
||||
};
|
||||
|
||||
const createResolvers = (enumValue) => {
|
||||
// In entity filters, every item of the supported features, a feature or a
|
||||
// list of features, becomes one bit mask
|
||||
const featureMask = (features) =>
|
||||
[].concat(features).reduce(
|
||||
// eslint-disable-next-line no-bitwise
|
||||
(mask, feature) => mask | enumValue(feature),
|
||||
0
|
||||
);
|
||||
|
||||
const resolveSelector = (value) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(resolveSelector);
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, val]) => [
|
||||
key,
|
||||
key === "supported_features"
|
||||
? [].concat(val).map(featureMask)
|
||||
: resolveSelector(val),
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
// Like core, an empty target allows any target
|
||||
const resolveTarget = (target) => resolveSelector(target ?? {});
|
||||
|
||||
// Field filters keep their lists, with every enum resolved on its own
|
||||
const resolveField = (field) => {
|
||||
const result = { ...field };
|
||||
if (field.selector) {
|
||||
result.selector = resolveSelector(field.selector);
|
||||
}
|
||||
if (field.filter?.supported_features) {
|
||||
result.filter = {
|
||||
...result.filter,
|
||||
supported_features: field.filter.supported_features.map(enumValue),
|
||||
};
|
||||
}
|
||||
if (field.filter?.attribute) {
|
||||
result.filter = {
|
||||
...result.filter,
|
||||
attribute: Object.fromEntries(
|
||||
Object.entries(field.filter.attribute).map(([name, options]) => [
|
||||
name,
|
||||
options.map(enumValue),
|
||||
])
|
||||
),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Fields can be grouped in sections, which hold fields themselves
|
||||
const resolveFields = (fields) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(fields ?? {}).map(([key, field]) => [
|
||||
key,
|
||||
field && "fields" in field && !("selector" in field)
|
||||
? { ...field, fields: resolveFields(field.fields) }
|
||||
: resolveField(field ?? {}),
|
||||
])
|
||||
);
|
||||
|
||||
return { resolveFields, resolveTarget };
|
||||
};
|
||||
|
||||
// Mirrors how core builds the response of get_services
|
||||
const serviceDescriptions = (yamls, { resolveFields, resolveTarget }) =>
|
||||
Object.fromEntries(
|
||||
SERVICE_DOMAINS.map((domain) => {
|
||||
if (!yamls[domain]) {
|
||||
throw new Error(`No services.yaml found for ${domain}`);
|
||||
}
|
||||
const services = Object.entries(yamls[domain])
|
||||
.filter(
|
||||
([service]) =>
|
||||
!service.startsWith(".") &&
|
||||
!UNREGISTERED_SERVICES.includes(`${domain}.${service}`)
|
||||
)
|
||||
.map(([service, value]) => {
|
||||
const description = { fields: resolveFields(value?.fields) };
|
||||
for (const item of ["description", "name"]) {
|
||||
if (value && item in value) {
|
||||
description[item] = value[item];
|
||||
}
|
||||
}
|
||||
if (value && "target" in value) {
|
||||
description.target = resolveTarget(value.target);
|
||||
}
|
||||
const key = `${domain}.${service}`;
|
||||
if (key in RESPONSE_SERVICES) {
|
||||
description.response = { optional: RESPONSE_SERVICES[key] };
|
||||
}
|
||||
return [service, description];
|
||||
});
|
||||
return [domain, Object.fromEntries(services)];
|
||||
})
|
||||
);
|
||||
|
||||
// Mirrors how core builds the trigger and condition platform descriptions
|
||||
const platformDescriptions = (yamls, { resolveFields, resolveTarget }) =>
|
||||
Object.fromEntries(
|
||||
AUTOMATION_PLATFORM_DOMAINS.flatMap((domain) =>
|
||||
Object.entries(yamls[domain] ?? {})
|
||||
// Keys starting with a dot only hold YAML anchors
|
||||
.filter(([key, value]) => !key.startsWith(".") && value)
|
||||
.map(([key, value]) => {
|
||||
const absoluteKey = !key.startsWith("_")
|
||||
? `${domain}.${key}`
|
||||
: key.slice(1) || domain;
|
||||
const description = { fields: resolveFields(value.fields) };
|
||||
if ("target" in value) {
|
||||
description.target = resolveTarget(value.target);
|
||||
}
|
||||
return [absoluteKey, description];
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// The trigger or condition icons of every domain that has them
|
||||
const platformIcons = (icons, category) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(icons)
|
||||
.map(([domain, content]) => [
|
||||
domain,
|
||||
content ? JSON.parse(content)[category] : undefined,
|
||||
])
|
||||
.filter(([, categoryIcons]) => categoryIcons)
|
||||
);
|
||||
|
||||
const parseYamls = (files) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(files)
|
||||
.filter(([, content]) => content !== undefined)
|
||||
.map(([domain, content]) => [domain, parseYaml(content)])
|
||||
);
|
||||
|
||||
const writeTs = async (path, content) => {
|
||||
const file = join(paths.root_dir, path);
|
||||
const options = await prettier.resolveConfig(file);
|
||||
await writeFile(
|
||||
file,
|
||||
await prettier.format(`${HEADER}\n${content}`, {
|
||||
...options,
|
||||
filepath: file,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
gulp.task("gen-demo-core-data", async () => {
|
||||
const [servicesFiles, triggersFiles, conditionsFiles, iconsFiles] =
|
||||
await Promise.all([
|
||||
fetchCoreFiles(SERVICE_DOMAINS, "services.yaml"),
|
||||
fetchCoreFiles(AUTOMATION_PLATFORM_DOMAINS, "triggers.yaml"),
|
||||
fetchCoreFiles(AUTOMATION_PLATFORM_DOMAINS, "conditions.yaml"),
|
||||
fetchCoreFiles(AUTOMATION_PLATFORM_DOMAINS, "icons.json"),
|
||||
]);
|
||||
const servicesYamls = parseYamls(servicesFiles);
|
||||
const triggersYamls = parseYamls(triggersFiles);
|
||||
const conditionsYamls = parseYamls(conditionsFiles);
|
||||
const resolvers = createResolvers(
|
||||
await enumResolver([servicesYamls, triggersYamls, conditionsYamls])
|
||||
);
|
||||
for (const key of UNREGISTERED_SERVICES) {
|
||||
const [domain, service] = key.split(".");
|
||||
if (!(service in (servicesYamls[domain] ?? {}))) {
|
||||
throw new Error(`Remove ${key} from UNREGISTERED_SERVICES, it is gone`);
|
||||
}
|
||||
}
|
||||
const services = serviceDescriptions(servicesYamls, resolvers);
|
||||
for (const key of Object.keys(RESPONSE_SERVICES)) {
|
||||
const [domain, service] = key.split(".");
|
||||
if (!services[domain]?.[service]) {
|
||||
throw new Error(`Service ${key} that returns a response does not exist`);
|
||||
}
|
||||
}
|
||||
const triggers = platformDescriptions(triggersYamls, resolvers);
|
||||
const conditions = platformDescriptions(conditionsYamls, resolvers);
|
||||
const icons = {
|
||||
triggers: platformIcons(iconsFiles, "triggers"),
|
||||
conditions: platformIcons(iconsFiles, "conditions"),
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
writeTs(
|
||||
"src/fake_data/demo_services.ts",
|
||||
`import type { HassServices } from "home-assistant-js-websocket";
|
||||
|
||||
// Core sends more than the HassServices type covers, like sections and list
|
||||
// examples.
|
||||
export const demoServices = ${JSON.stringify(services)} as unknown as HassServices;
|
||||
`
|
||||
),
|
||||
writeTs(
|
||||
"demo/src/stubs/automation_platform_domains.ts",
|
||||
`
|
||||
// Integrations that provide the new style triggers and conditions. Their
|
||||
// translations are part of the demo build.
|
||||
export const automationPlatformDomains = ${JSON.stringify(AUTOMATION_PLATFORM_DOMAINS)};
|
||||
`
|
||||
),
|
||||
writeTs(
|
||||
"demo/src/stubs/automation_platforms_data.ts",
|
||||
`import type { ConditionDescriptions } from "../../../src/data/condition";
|
||||
import type { TriggerDescriptions } from "../../../src/data/trigger";
|
||||
|
||||
export const triggerDescriptions: TriggerDescriptions = ${JSON.stringify(triggers)};
|
||||
|
||||
export const conditionDescriptions: ConditionDescriptions = ${JSON.stringify(conditions)};
|
||||
`
|
||||
),
|
||||
writeTs(
|
||||
"demo/src/stubs/automation_platforms_icons.ts",
|
||||
`
|
||||
export const triggerIcons = ${JSON.stringify(icons.triggers)};
|
||||
|
||||
export const conditionIcons = ${JSON.stringify(icons.conditions)};
|
||||
`
|
||||
),
|
||||
]);
|
||||
});
|
||||
@@ -69,7 +69,7 @@ export const SENSOR_STATE_CLASSES: string[] = [
|
||||
${stateClasses.map((stateClass) => ` "${stateClass}",`).join("\n")}
|
||||
];
|
||||
|
||||
export const SENSOR_STATE_CLASS_UNITS: Record<string, string[]> = {
|
||||
export const SENSOR_STATE_CLASS_UNITS: Record<string, (string | null)[]> = {
|
||||
${Object.entries(stateClassUnits)
|
||||
.map(
|
||||
([stateClass, units]) =>
|
||||
|
||||
@@ -6,9 +6,10 @@ import "./demo.js";
|
||||
import "./download-translations.js";
|
||||
import "./e2e-test-app.js";
|
||||
import "./entry-html.js";
|
||||
import "./fetch-nightly-translations.js";
|
||||
import "./fetch-nightly-translations.mts";
|
||||
import "./gallery.js";
|
||||
import "./gather-static.js";
|
||||
import "./gen-demo-core-data.js";
|
||||
import "./gen-device-classes.js";
|
||||
import "./gen-icons-json.js";
|
||||
import "./gen-sensor-entity-constants.js";
|
||||
|
||||
@@ -29,7 +29,7 @@ const LICENSE_OVERRIDES = [
|
||||
// type-fest ships two license files (MIT for code, CC0 for types).
|
||||
// We use the MIT license since that covers the bundled code.
|
||||
packageName: "type-fest",
|
||||
version: "5.8.0",
|
||||
version: "5.10.0",
|
||||
licenseFile: "license-mit",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -10,7 +10,7 @@ import { finished } from "node:stream/promises";
|
||||
import env from "../env.cjs";
|
||||
import paths from "../paths.cjs";
|
||||
import { mergeTranslations } from "./merge-translations.js";
|
||||
import "./fetch-nightly-translations.js";
|
||||
import "./fetch-nightly-translations.mts";
|
||||
|
||||
const inFrontendDir = "translations/frontend";
|
||||
const inBackendDir = "translations/backend";
|
||||
|
||||
@@ -54,9 +54,9 @@ const CONFIG_FILES = [
|
||||
path.join(__dirname, "babel-plugins", "inline-constants-plugin.cjs"),
|
||||
];
|
||||
|
||||
// Yarn patches change a package's files but not its version, which is all the
|
||||
// Patches change a package's files but not its version, which is all the
|
||||
// node_modules snapshot checks, so their contents go into the version too.
|
||||
const YARN_PATCHES_DIR = path.join(paths.root_dir, ".yarn", "patches");
|
||||
const PATCHES_DIR = path.join(paths.root_dir, "patches");
|
||||
|
||||
// Content hash of the toolchain versions and our own build files, used as the
|
||||
// persistent cache `version`. Everything here is path-independent so the cache
|
||||
@@ -69,12 +69,12 @@ const cacheVersion = () => {
|
||||
...CONFIG_FILES.map(
|
||||
(file) => `${path.basename(file)}:${fs.readFileSync(file, "utf8")}`
|
||||
),
|
||||
...(existsSync(YARN_PATCHES_DIR) ? fs.readdirSync(YARN_PATCHES_DIR) : [])
|
||||
...(existsSync(PATCHES_DIR) ? fs.readdirSync(PATCHES_DIR) : [])
|
||||
.filter((file) => file.endsWith(".patch"))
|
||||
.sort()
|
||||
.map(
|
||||
(file) =>
|
||||
`${file}:${fs.readFileSync(path.join(YARN_PATCHES_DIR, file), "utf8")}`
|
||||
`${file}:${fs.readFileSync(path.join(PATCHES_DIR, file), "utf8")}`
|
||||
),
|
||||
];
|
||||
return require("crypto")
|
||||
@@ -115,7 +115,10 @@ const createRspackConfig = ({
|
||||
dontHash = new Set();
|
||||
}
|
||||
const ignorePackages = bundle.ignorePackages({ latestBuild });
|
||||
const litHtmlRoot = path.resolve(__dirname, "../node_modules/lit-html");
|
||||
// pnpm links node_modules/lit-html into its store; rspack compares real paths
|
||||
const litHtmlRoot = fs.realpathSync(
|
||||
path.resolve(__dirname, "../node_modules/lit-html")
|
||||
);
|
||||
const litHtmlDevelopmentRoot = path.join(litHtmlRoot, "development");
|
||||
const litDisableDevModeLoader = path.join(
|
||||
__dirname,
|
||||
@@ -489,7 +492,7 @@ const createRspackConfig = ({
|
||||
// `name` is already unique per variant (frontend-modern/-legacy).
|
||||
name,
|
||||
// Content-based version (node major + toolchain versions + our own
|
||||
// build files + Yarn patches). Everything is path-independent, so
|
||||
// build files + patches). Everything is path-independent, so
|
||||
// the cache stays valid when reused on another machine/checkout.
|
||||
// Runtime deps are deliberately absent — rspack's node_modules
|
||||
// snapshot invalidates their modules per-package, so a single
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
LovelaceConfig,
|
||||
LovelaceDashboardStrategyConfig,
|
||||
} from "../../../../src/data/lovelace/config/types";
|
||||
import { normalizeLovelaceConfig } from "../../../../src/data/lovelace/config/normalize";
|
||||
import { isStrategyDashboard } from "../../../../src/data/lovelace/config/types";
|
||||
import { fetchResources } from "../../../../src/data/lovelace/resource";
|
||||
import { loadLovelaceResources } from "../../../../src/panels/lovelace/common/load-resources";
|
||||
@@ -34,7 +35,6 @@ import { HassElement } from "../../../../src/state/hass-element";
|
||||
import { castContext } from "../cast_context";
|
||||
import "./hc-launch-screen";
|
||||
import { getPanelTitleFromUrlPath } from "../../../../src/data/panel";
|
||||
import { checkLovelaceConfig } from "../../../../src/panels/lovelace/common/check-lovelace-config";
|
||||
|
||||
const DEFAULT_CONFIG: LovelaceDashboardStrategyConfig = {
|
||||
strategy: {
|
||||
@@ -359,7 +359,7 @@ export class HcMain extends HassElement {
|
||||
this._urlPath || "lovelace"
|
||||
);
|
||||
castContext.setApplicationState(title || "");
|
||||
this._lovelaceConfig = checkLovelaceConfig(
|
||||
this._lovelaceConfig = normalizeLovelaceConfig(
|
||||
lovelaceConfig
|
||||
) as LovelaceConfig;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { navigate } from "../../../src/common/navigate";
|
||||
import { slugify } from "../../../src/common/string/slugify";
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import type { Lovelace } from "../../../src/panels/lovelace/types";
|
||||
import { setDemoAreas } from "../stubs/area_registry";
|
||||
import { setPlatformEntityRegistryEntries } from "../stubs/entity_registry";
|
||||
import { energyEntities } from "../stubs/entities";
|
||||
import { connectivityEntities } from "../stubs/connectivity/fixtures";
|
||||
import { setDemoFloors } from "../stubs/floor_registry";
|
||||
import { getDemoTheme } from "../stubs/frontend";
|
||||
import type { DemoConfig, DemoTheme } from "./types";
|
||||
import type { EntityInput } from "../../../src/fake_data/entities/types";
|
||||
import type { DemoAutomation, DemoConfig, DemoTheme } from "./types";
|
||||
|
||||
export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
|
||||
if (typeof theme === "function") {
|
||||
@@ -16,6 +20,95 @@ export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
|
||||
hass.mockTheme(null, getDemoTheme(theme));
|
||||
};
|
||||
|
||||
export const automationEntity = ({
|
||||
config: automation,
|
||||
entityId,
|
||||
state = "on",
|
||||
icon,
|
||||
lastTriggered,
|
||||
}: DemoAutomation): EntityInput => ({
|
||||
entity_id: entityId ?? `automation.${slugify(automation.alias)}`,
|
||||
platform: "automation",
|
||||
state,
|
||||
attributes: {
|
||||
id: automation.id,
|
||||
friendly_name: automation.alias,
|
||||
last_triggered:
|
||||
lastTriggered === undefined
|
||||
? null
|
||||
: new Date(Date.now() - lastTriggered * 60000).toISOString(),
|
||||
mode: automation.mode ?? "single",
|
||||
current: 0,
|
||||
// Like core, only the modes that can run more than once have a maximum
|
||||
...((automation.mode === "queued" || automation.mode === "parallel") && {
|
||||
max: automation.max ?? 10,
|
||||
}),
|
||||
icon,
|
||||
},
|
||||
});
|
||||
|
||||
// Automations saved in the editor during this session, per demo
|
||||
// Automations saved in the editor during this session, per demo. A deleted
|
||||
// automation is null.
|
||||
const savedAutomations: Record<
|
||||
string,
|
||||
Record<string, DemoAutomation | null>
|
||||
> = {};
|
||||
|
||||
export const saveDemoAutomation = (
|
||||
id: string,
|
||||
automation: DemoAutomation | null
|
||||
) => {
|
||||
savedAutomations[selectedDemo] = {
|
||||
...savedAutomations[selectedDemo],
|
||||
[id]: automation,
|
||||
};
|
||||
};
|
||||
|
||||
// The automations of a demo config, with the ones saved in the editor
|
||||
export const demoAutomations = (
|
||||
demo: string,
|
||||
config: DemoConfig
|
||||
): DemoAutomation[] => {
|
||||
const saved = savedAutomations[demo] ?? {};
|
||||
const configured = config.automations ?? [];
|
||||
const ids = new Set(configured.map((automation) => automation.config.id));
|
||||
return [
|
||||
...configured.map((automation) =>
|
||||
automation.config.id in saved ? saved[automation.config.id] : automation
|
||||
),
|
||||
...Object.values(saved).filter(
|
||||
(automation) => automation && !ids.has(automation.config.id)
|
||||
),
|
||||
].filter((automation): automation is DemoAutomation => automation !== null);
|
||||
};
|
||||
|
||||
// The automation entities are derived from the configs the editor shows, so
|
||||
// their names and IDs always match.
|
||||
export const demoConfigEntities = (
|
||||
demo: string,
|
||||
config: DemoConfig,
|
||||
localize: LocalizeFunc
|
||||
): EntityInput[] => [
|
||||
...config.entities(localize),
|
||||
...demoAutomations(demo, config).map(automationEntity),
|
||||
];
|
||||
|
||||
// Like core, the automations of a demo config are in the entity registry
|
||||
export const registerDemoAutomations = (
|
||||
hass: MockHomeAssistant,
|
||||
demo: string,
|
||||
config: DemoConfig
|
||||
) =>
|
||||
setPlatformEntityRegistryEntries(
|
||||
hass,
|
||||
"automation",
|
||||
demoAutomations(demo, config).map((automation) => ({
|
||||
entity_id: automationEntity(automation).entity_id,
|
||||
unique_id: automation.config.id,
|
||||
}))
|
||||
);
|
||||
|
||||
export const demoConfigs: Record<string, () => Promise<DemoConfig>> = {
|
||||
sections: () => import("./sections").then((mod) => mod.demoSections),
|
||||
home: () => import("./home").then((mod) => mod.demoHome),
|
||||
@@ -52,7 +145,8 @@ export const setDemoConfig = async (
|
||||
|
||||
setDemoFloors(hass, config.floors);
|
||||
setDemoAreas(hass, config.areas);
|
||||
hass.addEntities(config.entities(hass.localize), true);
|
||||
hass.addEntities(demoConfigEntities(demo, config, hass.localize), true);
|
||||
registerDemoAutomations(hass, demo, config);
|
||||
hass.addEntities(energyEntities());
|
||||
// Replaced the whole state map above, so the entities that do not belong to a
|
||||
// demo config have to be added back.
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { HomeDashboardStrategyConfig } from "../../../../src/panels/lovelac
|
||||
import { HomeDashboardStrategy } from "../../../../src/panels/lovelace/strategies/home/home-dashboard-strategy";
|
||||
import type { HomeOverviewViewStrategyConfig } from "../../../../src/panels/lovelace/strategies/home/home-overview-view-strategy";
|
||||
import { HomeOverviewViewStrategy } from "../../../../src/panels/lovelace/strategies/home/home-overview-view-strategy";
|
||||
import { generateLovelaceSectionStrategy } from "../../../../src/panels/lovelace/strategies/get-strategy";
|
||||
import type { HeadingCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
export interface DemoHomeDashboardStrategyConfig extends Omit<
|
||||
@@ -61,29 +61,33 @@ class DemoHomeOverviewViewStrategy extends ReactiveElement {
|
||||
{ ...config, type: "home-overview" },
|
||||
hass
|
||||
);
|
||||
// Expand the favorites section so the demo card can be added to it
|
||||
const sections = await Promise.all(
|
||||
(view.sections || []).map(async (section) => {
|
||||
if (
|
||||
!isStrategySection(section) ||
|
||||
section.strategy.type !== "common-controls"
|
||||
) {
|
||||
return section;
|
||||
}
|
||||
// The demo card takes up the space of two tiles
|
||||
const limit = (section.strategy.limit as number | undefined) ?? 8;
|
||||
const favorites = await generateLovelaceSectionStrategy(
|
||||
{ ...section, strategy: { ...section.strategy, limit: limit - 2 } },
|
||||
hass
|
||||
);
|
||||
const [heading, ...cards] = favorites.cards || [];
|
||||
return {
|
||||
...favorites,
|
||||
// Place the demo card first so the tiles fill the rows next to it
|
||||
cards: [heading, { type: "custom:ha-demo-next-card" }, ...cards],
|
||||
};
|
||||
})
|
||||
// Add the demo card to the favorites section, found by its heading
|
||||
const favoritesHeading = hass.localize(
|
||||
"ui.panel.lovelace.strategy.home.favorites"
|
||||
);
|
||||
const sections = (view.sections || []).map((section) => {
|
||||
if (
|
||||
isStrategySection(section) ||
|
||||
!section.cards?.some(
|
||||
(card) =>
|
||||
card.type === "heading" &&
|
||||
(card as HeadingCardConfig).heading === favoritesHeading
|
||||
)
|
||||
) {
|
||||
return section;
|
||||
}
|
||||
const [heading, ...cards] = section.cards;
|
||||
return {
|
||||
...section,
|
||||
// Place the demo card first so the tiles fill the rows next to it. It
|
||||
// takes up the space of two tiles, so keep two less, but at least six.
|
||||
cards: [
|
||||
heading,
|
||||
{ type: "custom:ha-demo-next-card" },
|
||||
...cards.slice(0, Math.max(6, cards.length - 2)),
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...view,
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import type { DemoAutomation } from "../types";
|
||||
|
||||
export const demoAutomationsSections: DemoAutomation[] = [
|
||||
{
|
||||
lastTriggered: 60 * 9,
|
||||
state: "off",
|
||||
icon: "mdi:auto-mode",
|
||||
config: {
|
||||
id: "1700669321947",
|
||||
alias: "Home Assistant Auto-update",
|
||||
description:
|
||||
"Installs Home Assistant updates at night, so nobody is bothered by the restart.",
|
||||
triggers: [{ trigger: "time", at: "03:00:00" }],
|
||||
conditions: [
|
||||
{
|
||||
condition: "update.is_available",
|
||||
target: { entity_id: "update.home_assistant_core_update" },
|
||||
options: { for: "00:00:00" },
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
action: "update.install",
|
||||
target: { entity_id: "update.home_assistant_core_update" },
|
||||
data: { backup: true },
|
||||
},
|
||||
],
|
||||
mode: "queued",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 25,
|
||||
config: {
|
||||
id: "1700669322001",
|
||||
alias: "Kitchen lights on motion",
|
||||
description:
|
||||
"Turns on the kitchen lights when motion is detected and turns them off again once the kitchen has been empty for 2 minutes.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "motion.detected",
|
||||
target: { entity_id: "binary_sensor.kitchen_motion" },
|
||||
options: { for: "00:00:00" },
|
||||
},
|
||||
],
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: {
|
||||
entity_id: ["light.kitchen_spotlights", "light.worktop_spotlights"],
|
||||
},
|
||||
data: { brightness_pct: 80 },
|
||||
},
|
||||
{
|
||||
wait_for_trigger: [
|
||||
{
|
||||
trigger: "motion.cleared",
|
||||
target: { entity_id: "binary_sensor.kitchen_motion" },
|
||||
options: { for: "00:02:00" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
action: "light.turn_off",
|
||||
target: {
|
||||
entity_id: ["light.kitchen_spotlights", "light.worktop_spotlights"],
|
||||
},
|
||||
},
|
||||
],
|
||||
mode: "restart",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 60 * 3,
|
||||
config: {
|
||||
id: "1700669322002",
|
||||
alias: "Outdoor light from sunset to sunrise",
|
||||
description:
|
||||
"Uses trigger IDs to handle both sunset and sunrise in one automation.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "sun.sunset",
|
||||
id: "sunset",
|
||||
options: {
|
||||
offset: { days: 0, hours: 0, minutes: 15, seconds: 0 },
|
||||
offset_type: "before",
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: "sun.sunrise",
|
||||
id: "sunrise",
|
||||
options: {
|
||||
offset: { days: 0, hours: 0, minutes: 0, seconds: 0 },
|
||||
offset_type: "before",
|
||||
},
|
||||
},
|
||||
],
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
choose: [
|
||||
{
|
||||
conditions: [{ condition: "trigger", id: "sunset" }],
|
||||
sequence: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.outdoor_light" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
conditions: [{ condition: "trigger", id: "sunrise" }],
|
||||
sequence: [
|
||||
{
|
||||
action: "light.turn_off",
|
||||
target: { entity_id: "light.outdoor_light" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 60 * 14,
|
||||
config: {
|
||||
id: "1700669322003",
|
||||
alias: "Flood light on motion when dark",
|
||||
description:
|
||||
"Turns on the flood light for 5 minutes when someone walks by in the dark.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "motion.detected",
|
||||
target: { entity_id: "binary_sensor.outdoor_motion_sensor_motion" },
|
||||
options: { for: "00:00:00" },
|
||||
},
|
||||
],
|
||||
conditions: [
|
||||
{
|
||||
condition: "illuminance.is_value",
|
||||
target: { entity_id: "sensor.outdoor_motion_sensor_illuminance" },
|
||||
options: {
|
||||
threshold: {
|
||||
type: "below",
|
||||
value: { number: 50, unit_of_measurement: "lx" },
|
||||
},
|
||||
for: "00:00:00",
|
||||
},
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.flood_light" },
|
||||
},
|
||||
{ delay: { hours: 0, minutes: 5, seconds: 0, milliseconds: 0 } },
|
||||
{
|
||||
action: "light.turn_off",
|
||||
target: { entity_id: "light.flood_light" },
|
||||
},
|
||||
],
|
||||
mode: "restart",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 60 * 26,
|
||||
config: {
|
||||
id: "1700669322004",
|
||||
alias: "Close the shutters when it gets hot",
|
||||
description:
|
||||
"Keeps the living room cool by closing the shutters on hot, sunny days.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "temperature.crossed_threshold",
|
||||
target: { entity_id: "sensor.outdoor_temperature" },
|
||||
options: {
|
||||
threshold: {
|
||||
type: "above",
|
||||
value: { number: 25, unit_of_measurement: "°C" },
|
||||
},
|
||||
for: "00:10:00",
|
||||
},
|
||||
},
|
||||
],
|
||||
conditions: [{ condition: "sun.is_up" }],
|
||||
actions: [
|
||||
{
|
||||
action: "cover.close_cover",
|
||||
target: {
|
||||
entity_id: [
|
||||
"cover.living_room_garden_shutter",
|
||||
"cover.living_room_graveyard_shutter",
|
||||
"cover.living_room_left_shutter",
|
||||
"cover.living_room_right_shutter",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 60 * 50,
|
||||
config: {
|
||||
id: "1700669322005",
|
||||
alias: "Fridge door left open",
|
||||
description:
|
||||
"Sends a reminder when the fridge door has been open for 2 minutes.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "door.opened",
|
||||
target: { entity_id: "binary_sensor.fridge_door" },
|
||||
options: { for: "00:02:00" },
|
||||
},
|
||||
],
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
action: "notify.notify",
|
||||
data: {
|
||||
title: "Fridge door",
|
||||
message: "The fridge door has been open for 2 minutes.",
|
||||
},
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 90,
|
||||
config: {
|
||||
id: "1700669322006",
|
||||
alias: "Meeting mode",
|
||||
description:
|
||||
"Pauses the music in the study and turns on the lights when a meeting starts.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "switch.turned_on",
|
||||
target: { entity_id: "switch.in_meeting" },
|
||||
options: { for: "00:00:00" },
|
||||
},
|
||||
],
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
action: "media_player.media_pause",
|
||||
target: { entity_id: "media_player.study_nest_hub" },
|
||||
},
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.study_spotlights" },
|
||||
data: { brightness_pct: 100, color_temp_kelvin: 4000 },
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 60 * 20,
|
||||
config: {
|
||||
id: "1700669322009",
|
||||
alias: "Lights up when the music pauses",
|
||||
description:
|
||||
"Turns on the living room spotlights when the music is paused in the evening.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "media_player.paused_playing",
|
||||
target: { entity_id: "media_player.living_room_nest_mini" },
|
||||
options: { for: "00:00:00" },
|
||||
},
|
||||
],
|
||||
conditions: [{ condition: "sun.is_set" }],
|
||||
actions: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.living_room_spotlights" },
|
||||
data: { brightness_pct: 40 },
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 60 * 7,
|
||||
config: {
|
||||
id: "1700669322007",
|
||||
alias: "Lower heating when the car leaves",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "zone.left",
|
||||
target: { entity_id: "device_tracker.car" },
|
||||
options: { zone: "zone.home", for: "00:00:00" },
|
||||
},
|
||||
],
|
||||
conditions: [
|
||||
{
|
||||
condition: "time",
|
||||
weekday: ["mon", "tue", "wed", "thu", "fri"],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
action: "climate.set_temperature",
|
||||
target: {
|
||||
entity_id: ["climate.ground_floor", "climate.first_floor"],
|
||||
},
|
||||
data: { temperature: 17 },
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 60 * 11,
|
||||
config: {
|
||||
id: "1700669322008",
|
||||
alias: "Good night",
|
||||
description:
|
||||
"Turns off all lights and closes the shutters at 23:00 on weeknights.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "time",
|
||||
at: "23:00:00",
|
||||
weekday: ["sun", "mon", "tue", "wed", "thu"],
|
||||
},
|
||||
],
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
action: "light.turn_off",
|
||||
target: {
|
||||
entity_id: [
|
||||
"light.floor_lamp",
|
||||
"light.living_room_spotlights",
|
||||
"light.bar_lamp",
|
||||
"light.kitchen_spotlights",
|
||||
"light.worktop_spotlights",
|
||||
"light.study_spotlights",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "cover.close_cover",
|
||||
target: {
|
||||
entity_id: [
|
||||
"cover.living_room_garden_shutter",
|
||||
"cover.living_room_graveyard_shutter",
|
||||
"cover.living_room_left_shutter",
|
||||
"cover.living_room_right_shutter",
|
||||
"cover.kitchen_shutter",
|
||||
"cover.study_shutter",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -140,6 +140,18 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
|
||||
friendly_name: "Outdoor humidity",
|
||||
},
|
||||
},
|
||||
// Used by the automation for when the car leaves home
|
||||
"zone.home": {
|
||||
entity_id: "zone.home",
|
||||
state: "0",
|
||||
attributes: {
|
||||
latitude: 52.3731339,
|
||||
longitude: 4.8903147,
|
||||
radius: 100,
|
||||
friendly_name: "Home",
|
||||
icon: "mdi:home",
|
||||
},
|
||||
},
|
||||
"device_tracker.car": {
|
||||
entity_id: "device_tracker.car",
|
||||
state: "not_home",
|
||||
@@ -514,19 +526,6 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
|
||||
friendly_name: "Outdoor motion sensor Illuminance",
|
||||
},
|
||||
},
|
||||
"automation.home_assistant_auto_update": {
|
||||
entity_id: "automation.home_assistant_auto_update",
|
||||
state: "off",
|
||||
attributes: {
|
||||
id: "1700669321947",
|
||||
last_triggered: "2024-02-29T18:02:05.343139+00:00",
|
||||
mode: "queued",
|
||||
current: 0,
|
||||
max: 50,
|
||||
icon: "mdi:auto-mode",
|
||||
friendly_name: "Home Assistant Auto-update",
|
||||
},
|
||||
},
|
||||
"update.home_assistant_operating_system_update": {
|
||||
entity_id: "update.home_assistant_operating_system_update",
|
||||
state: "off",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DemoConfig } from "../types";
|
||||
import { demoAutomationsSections } from "./automations";
|
||||
import { demoEntitiesSections } from "./entities";
|
||||
import { demoLovelaceSections } from "./lovelace";
|
||||
|
||||
@@ -8,5 +9,6 @@ export const demoSections: DemoConfig = {
|
||||
name: "Home Demo",
|
||||
lovelace: demoLovelaceSections,
|
||||
entities: demoEntitiesSections,
|
||||
automations: demoAutomationsSections,
|
||||
theme: { theme: "default", dark: false },
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { ManualAutomationConfig } from "../../../src/data/automation";
|
||||
import type { LovelaceRawConfig } from "../../../src/data/lovelace/config/types";
|
||||
import type { EntityInput } from "../../../src/fake_data/entities/types";
|
||||
import type { ThemeSettings } from "../../../src/types";
|
||||
@@ -8,6 +9,16 @@ import type { DemoFloor } from "../stubs/floor_registry";
|
||||
|
||||
export type DemoTheme = ThemeSettings | (() => Record<string, string> | null);
|
||||
|
||||
export interface DemoAutomation {
|
||||
config: ManualAutomationConfig & { id: string; alias: string };
|
||||
/** Defaults to the alias as slug */
|
||||
entityId?: string;
|
||||
state?: "on" | "off";
|
||||
icon?: string;
|
||||
/** Minutes ago */
|
||||
lastTriggered?: number;
|
||||
}
|
||||
|
||||
export interface DemoConfig {
|
||||
index?: number;
|
||||
name: string;
|
||||
@@ -17,6 +28,7 @@ export interface DemoConfig {
|
||||
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
|
||||
lovelace: (localize: LocalizeFunc) => LovelaceRawConfig;
|
||||
entities: (localize: LocalizeFunc) => EntityInput[];
|
||||
automations?: DemoAutomation[];
|
||||
floors?: DemoFloor[];
|
||||
areas?: DemoArea[];
|
||||
theme: DemoTheme;
|
||||
|
||||
+52
-12
@@ -1,11 +1,18 @@
|
||||
import { customElement } from "lit/decorators";
|
||||
import type { LocalizeFunc } from "../../src/common/translations/localize";
|
||||
import { isNavigationClick } from "../../src/common/dom/is-navigation-click";
|
||||
import { navigate } from "../../src/common/navigate";
|
||||
import type { MockHomeAssistant } from "../../src/fake_data/provide_hass";
|
||||
import { provideHass } from "../../src/fake_data/provide_hass";
|
||||
import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
|
||||
import {
|
||||
applyDemoTheme,
|
||||
demoConfigEntities,
|
||||
registerDemoAutomations,
|
||||
selectedDemo,
|
||||
selectedDemoConfig,
|
||||
} from "./configs/demo-configs";
|
||||
import { mockAreaRegistry, setDemoAreas } from "./stubs/area_registry";
|
||||
import {
|
||||
connectivityCommands,
|
||||
@@ -18,7 +25,10 @@ import { demoDevices } from "./stubs/devices";
|
||||
import { mockDeviceRegistry } from "./stubs/device_registry";
|
||||
import { mockEnergy } from "./stubs/energy";
|
||||
import { energyEntities } from "./stubs/entities";
|
||||
import { mockEntityRegistry } from "./stubs/entity_registry";
|
||||
import {
|
||||
mockEntityRegistry,
|
||||
mockEntityRegistryDisplay,
|
||||
} from "./stubs/entity_registry";
|
||||
import { mockEvents } from "./stubs/events";
|
||||
import { mockFloorRegistry, setDemoFloors } from "./stubs/floor_registry";
|
||||
import { mockFrontend } from "./stubs/frontend";
|
||||
@@ -26,9 +36,11 @@ import { mockHardware } from "./stubs/hardware";
|
||||
import { mockHassioSupervisor } from "./stubs/hassio_supervisor";
|
||||
import { mockIntegration } from "./stubs/integration";
|
||||
import { mockLabelRegistry } from "./stubs/label_registry";
|
||||
import { automationPlatformDomains } from "./stubs/automation_platform_domains";
|
||||
import { mockIcons } from "./stubs/icons";
|
||||
import { mockHistory } from "./stubs/history";
|
||||
import { mockLovelace } from "./stubs/lovelace";
|
||||
import { zoneRegistryEntries } from "./stubs/map";
|
||||
import { mockMediaPlayer } from "./stubs/media_player";
|
||||
import { mockPersistentNotification } from "./stubs/persistent_notification";
|
||||
import { mockRecorder } from "./stubs/recorder";
|
||||
@@ -57,6 +69,12 @@ const CONFIG_PANEL_COMMANDS = [
|
||||
"system_health/",
|
||||
"backup/",
|
||||
"automation/config",
|
||||
"trigger_platforms/subscribe",
|
||||
"condition_platforms/subscribe",
|
||||
"get_triggers_for_target",
|
||||
"get_conditions_for_target",
|
||||
"get_services_for_target",
|
||||
"extract_from_target",
|
||||
"script/config",
|
||||
"config/automation/config",
|
||||
"config/script/config",
|
||||
@@ -66,6 +84,7 @@ const CONFIG_PANEL_COMMANDS = [
|
||||
"assist_pipeline/",
|
||||
"config/entity_registry/settings/",
|
||||
"slugify",
|
||||
"marketplace/",
|
||||
...connectivityCommands,
|
||||
];
|
||||
|
||||
@@ -87,22 +106,40 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [
|
||||
...(hass.config?.components ?? []),
|
||||
"backup",
|
||||
"webhook",
|
||||
"usage_prediction",
|
||||
"assist_pipeline",
|
||||
"hassio",
|
||||
"hardware",
|
||||
...connectivityComponents,
|
||||
// Some automation platforms are loaded already
|
||||
...new Set([
|
||||
...(hass.config?.components ?? []),
|
||||
"backup",
|
||||
"webhook",
|
||||
"usage_prediction",
|
||||
"assist_pipeline",
|
||||
"hassio",
|
||||
"hardware",
|
||||
"marketplace",
|
||||
...connectivityComponents,
|
||||
...automationPlatformDomains,
|
||||
]),
|
||||
],
|
||||
},
|
||||
panels: {
|
||||
...hass.panels,
|
||||
// Opened from the settings page, not listed in the sidebar
|
||||
marketplace: {
|
||||
component_name: "marketplace",
|
||||
icon: null,
|
||||
title: null,
|
||||
config: null,
|
||||
url_path: "marketplace",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const localizePromise =
|
||||
// @ts-ignore
|
||||
this._loadFragmentTranslations(hass.language, "page-demo").then(
|
||||
() => this.hass!.localize
|
||||
// hass is not updated yet when another fragment started loading
|
||||
// meanwhile, so use the localize with these translations directly
|
||||
(localize?: LocalizeFunc) => localize ?? this.hass!.localize
|
||||
);
|
||||
|
||||
mockLovelace(hass, localizePromise);
|
||||
@@ -137,6 +174,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
mockFloorRegistry(hass);
|
||||
mockLabelRegistry(hass);
|
||||
mockUsagePrediction(hass);
|
||||
mockEntityRegistryDisplay(hass);
|
||||
mockEntityRegistry(hass, [
|
||||
{
|
||||
config_entry_id: "co2signal",
|
||||
@@ -181,6 +219,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
modified_at: 0,
|
||||
},
|
||||
...connectivityEntityRegistryEntries,
|
||||
...zoneRegistryEntries,
|
||||
]);
|
||||
|
||||
hass.addEntities(energyEntities());
|
||||
@@ -191,7 +230,8 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
([conf, localize]) => {
|
||||
setDemoFloors(hass, conf.floors);
|
||||
setDemoAreas(hass, conf.areas);
|
||||
hass.addEntities(conf.entities(localize));
|
||||
hass.addEntities(demoConfigEntities(selectedDemo, conf, localize));
|
||||
registerDemoAutomations(hass, selectedDemo, conf);
|
||||
applyDemoTheme(hass, conf.theme);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
html {
|
||||
background-color: var(--primary-background-color, #fafafa);
|
||||
color: var(--primary-text-color, #212121);
|
||||
/* Prevent iOS Safari from inflating text when rotating to landscape */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { OwnProfileMutableParams } from "../../../src/data/person";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockAuth = (hass: MockHomeAssistant) => {
|
||||
@@ -6,4 +7,22 @@ export const mockAuth = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("auth/sign_path", (msg: { path: string }) => ({
|
||||
path: msg.path,
|
||||
}));
|
||||
|
||||
let name: string | undefined;
|
||||
// The profile dialog refreshes the user subscription after saving, which
|
||||
// writes the updated user back to hass.user. Read the current user on each
|
||||
// call so later changes to it, such as a non-admin user, are kept.
|
||||
hass.mockWS("auth/current_user", (_msg, currentHass: MockHomeAssistant) =>
|
||||
name && currentHass.user ? { ...currentHass.user, name } : currentHass.user
|
||||
);
|
||||
hass.mockWS(
|
||||
"person/update_own_profile",
|
||||
(msg: Partial<OwnProfileMutableParams>, currentHass: MockHomeAssistant) => {
|
||||
if (msg.name) {
|
||||
name = msg.name;
|
||||
}
|
||||
|
||||
return { user_name: name ?? currentHass.user?.name, person: null };
|
||||
}
|
||||
);
|
||||
};
|
||||
+142
-56
@@ -1,69 +1,155 @@
|
||||
import type { AutomationConfig } from "../../../src/data/automation";
|
||||
import type { ScriptConfig } from "../../../src/data/script";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { computeDomain } from "../../../src/common/entity/compute_domain";
|
||||
import { slugify } from "../../../src/common/string/slugify";
|
||||
import {
|
||||
automationEntity,
|
||||
demoAutomations,
|
||||
saveDemoAutomation,
|
||||
selectedDemo,
|
||||
selectedDemoConfig,
|
||||
} from "../configs/demo-configs";
|
||||
import type { DemoAutomation } from "../configs/types";
|
||||
import { mockAutomationPlatforms } from "./automation_platforms";
|
||||
import {
|
||||
addEntityRegistryEntry,
|
||||
getEntityRegistryEntry,
|
||||
removeEntityRegistryEntry,
|
||||
} from "./entity_registry";
|
||||
|
||||
const demoAutomationConfig = (entityId: string): AutomationConfig => ({
|
||||
id: entityId.split(".")[1],
|
||||
alias: "Demo automation",
|
||||
description: "An example automation shown in the demo.",
|
||||
triggers: [
|
||||
{ trigger: "state", entity_id: "binary_sensor.basement_floor_wet" },
|
||||
],
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.bed_light" },
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
});
|
||||
const automationConfig = async (
|
||||
id: string | undefined
|
||||
): Promise<AutomationConfig> => {
|
||||
const automation = demoAutomations(
|
||||
selectedDemo,
|
||||
await selectedDemoConfig
|
||||
).find((a) => a.config.id === id);
|
||||
if (!automation) {
|
||||
throw new Error("Automation not found");
|
||||
}
|
||||
return automation.config;
|
||||
};
|
||||
|
||||
const demoScriptConfig = (): ScriptConfig => ({
|
||||
alias: "Demo script",
|
||||
description: "An example script shown in the demo.",
|
||||
sequence: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.bed_light" },
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
});
|
||||
const scriptConfig = (
|
||||
currentHass: MockHomeAssistant,
|
||||
entityId: string
|
||||
): ScriptConfig => {
|
||||
const alias =
|
||||
currentHass.states[entityId]?.attributes.friendly_name ?? entityId;
|
||||
return {
|
||||
alias,
|
||||
sequence: [
|
||||
{
|
||||
action: "persistent_notification.create",
|
||||
data: { message: `${alias} ran` },
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
};
|
||||
};
|
||||
|
||||
export const mockAutomation = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("automation/config", (msg: { entity_id: string }) => ({
|
||||
config: demoAutomationConfig(msg.entity_id),
|
||||
}));
|
||||
hass.mockWS("script/config", () => ({ config: demoScriptConfig() }));
|
||||
|
||||
hass.mockAPI(/config\/automation\/config\/.+/, () =>
|
||||
demoAutomationConfig("automation.demo")
|
||||
);
|
||||
hass.mockAPI(/config\/script\/config\/.+/, () => demoScriptConfig());
|
||||
|
||||
// Trigger/condition type pickers subscribe for integration-provided
|
||||
// platforms. The demo only uses the built-in ones, so emit empty records.
|
||||
hass.mockWS(
|
||||
"trigger_platforms/subscribe",
|
||||
(
|
||||
_msg,
|
||||
_hass,
|
||||
onChange?: (descriptions: Record<string, unknown>) => void
|
||||
) => {
|
||||
onChange?.({});
|
||||
return () => undefined;
|
||||
"automation/config",
|
||||
async (msg: { entity_id: string }, currentHass: MockHomeAssistant) => ({
|
||||
config: await automationConfig(
|
||||
currentHass.states[msg.entity_id]?.attributes.id
|
||||
),
|
||||
})
|
||||
);
|
||||
hass.mockAPI(
|
||||
/config\/automation\/config\/.+/,
|
||||
(currentHass, method, path, parameters) => {
|
||||
const id = decodeURIComponent(path.split("/").pop()!);
|
||||
const existing = Object.values(currentHass.states).find(
|
||||
(stateObj) =>
|
||||
computeDomain(stateObj.entity_id) === "automation" &&
|
||||
stateObj.attributes.id === id
|
||||
);
|
||||
if (method === "DELETE") {
|
||||
// Like core, remove the automation with its entity
|
||||
saveDemoAutomation(id, null);
|
||||
if (existing) {
|
||||
const { [existing.entity_id]: _state, ...states } =
|
||||
currentHass.states;
|
||||
const { [existing.entity_id]: _entity, ...entities } =
|
||||
currentHass.entities;
|
||||
currentHass.updateHass({ states, entities });
|
||||
// Otherwise a service call to it would add its state again
|
||||
delete currentHass.mockEntities[existing.entity_id];
|
||||
removeEntityRegistryEntry(currentHass, existing.entity_id);
|
||||
}
|
||||
return { result: "ok" };
|
||||
}
|
||||
if (method === "POST") {
|
||||
// Core stores the ID in the config
|
||||
const config = {
|
||||
...(parameters as DemoAutomation["config"]),
|
||||
id,
|
||||
};
|
||||
config.alias ||= id;
|
||||
// Like core, update the automation entity, or add it for a new one
|
||||
// A new automation gets a free entity ID, like core
|
||||
const slug = `automation.${slugify(config.alias)}`;
|
||||
let entityId = existing?.entity_id ?? slug;
|
||||
for (let i = 2; !existing && entityId in currentHass.states; i++) {
|
||||
entityId = `${slug}_${i}`;
|
||||
}
|
||||
const lastTriggered = existing?.attributes.last_triggered;
|
||||
const automation: DemoAutomation = {
|
||||
config,
|
||||
entityId,
|
||||
state: existing?.state as DemoAutomation["state"],
|
||||
icon: existing?.attributes.icon,
|
||||
lastTriggered: lastTriggered
|
||||
? (Date.now() - Date.parse(lastTriggered)) / 60000
|
||||
: undefined,
|
||||
};
|
||||
// Kept per demo, so it is still there after switching demos
|
||||
saveDemoAutomation(id, automation);
|
||||
// A display entry of a new automation is left over from another demo
|
||||
const display = existing ? currentHass.entities[entityId] : undefined;
|
||||
currentHass.addEntities(automationEntity(automation));
|
||||
// Adding the entity resets its display entry, keep what the registry
|
||||
// holds
|
||||
if (display) {
|
||||
const registryName = getEntityRegistryEntry(entityId)?.name;
|
||||
currentHass.entities[entityId] = {
|
||||
...currentHass.entities[entityId],
|
||||
...(registryName && { name: registryName }),
|
||||
area_id: display.area_id,
|
||||
labels: display.labels,
|
||||
icon: display.icon,
|
||||
hidden: display.hidden,
|
||||
};
|
||||
}
|
||||
if (!existing) {
|
||||
addEntityRegistryEntry(currentHass, {
|
||||
entity_id: entityId,
|
||||
platform: "automation",
|
||||
unique_id: id,
|
||||
});
|
||||
}
|
||||
return { result: "ok" };
|
||||
}
|
||||
return automationConfig(id);
|
||||
}
|
||||
);
|
||||
|
||||
// Scripts do not have demo configs yet, show the script under its own name.
|
||||
hass.mockWS(
|
||||
"condition_platforms/subscribe",
|
||||
(
|
||||
_msg,
|
||||
_hass,
|
||||
onChange?: (descriptions: Record<string, unknown>) => void
|
||||
) => {
|
||||
onChange?.({});
|
||||
return () => undefined;
|
||||
}
|
||||
"script/config",
|
||||
(msg: { entity_id: string }, currentHass: MockHomeAssistant) => ({
|
||||
config: scriptConfig(currentHass, msg.entity_id),
|
||||
})
|
||||
);
|
||||
hass.mockAPI(/config\/script\/config\/.+/, (currentHass, _method, path) =>
|
||||
scriptConfig(
|
||||
currentHass,
|
||||
`script.${decodeURIComponent(path.split("/").pop()!)}`
|
||||
)
|
||||
);
|
||||
|
||||
mockAutomationPlatforms(hass);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
// This file is auto-generated from Home Assistant Core. Do not edit by hand.
|
||||
// Regenerate with `script/gen_demo_core_data`.
|
||||
|
||||
// Integrations that provide the new style triggers and conditions. Their
|
||||
// translations are part of the demo build.
|
||||
export const automationPlatformDomains = [
|
||||
"air_quality",
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"battery",
|
||||
"button",
|
||||
"calendar",
|
||||
"climate",
|
||||
"counter",
|
||||
"cover",
|
||||
"door",
|
||||
"doorbell",
|
||||
"event",
|
||||
"fan",
|
||||
"garage_door",
|
||||
"gate",
|
||||
"humidifier",
|
||||
"humidity",
|
||||
"illuminance",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"moisture",
|
||||
"moon",
|
||||
"motion",
|
||||
"occupancy",
|
||||
"power",
|
||||
"remote",
|
||||
"scene",
|
||||
"schedule",
|
||||
"select",
|
||||
"siren",
|
||||
"sun",
|
||||
"switch",
|
||||
"temperature",
|
||||
"text",
|
||||
"timer",
|
||||
"todo",
|
||||
"update",
|
||||
"vacuum",
|
||||
"valve",
|
||||
"vibration",
|
||||
"water_heater",
|
||||
"window",
|
||||
"zone",
|
||||
];
|
||||
@@ -0,0 +1,270 @@
|
||||
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";
|
||||
|
||||
type Descriptions = TriggerDescriptions | ConditionDescriptions;
|
||||
|
||||
interface EntityFilter {
|
||||
integration?: string;
|
||||
domain?: string | string[];
|
||||
device_class?: string | string[];
|
||||
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,
|
||||
filter: EntityFilter
|
||||
) =>
|
||||
(!filter.integration ||
|
||||
hass.entities[entityId]?.platform === filter.integration) &&
|
||||
(!filter.domain ||
|
||||
ensureArray(filter.domain).includes(computeDomain(entityId))) &&
|
||||
(!filter.device_class ||
|
||||
ensureArray(filter.device_class).includes(
|
||||
hass.states[entityId]?.attributes.device_class ?? ""
|
||||
)) &&
|
||||
// Like core, the entity needs all the features of one of the feature sets
|
||||
(!filter.supported_features ||
|
||||
filter.supported_features.some(
|
||||
(features) =>
|
||||
// eslint-disable-next-line no-bitwise
|
||||
((hass.states[entityId]?.attributes.supported_features ?? 0) &
|
||||
features) ===
|
||||
features
|
||||
));
|
||||
|
||||
type TargetDescriptions = Record<
|
||||
string,
|
||||
{
|
||||
target?: {
|
||||
entity?: EntityFilter | EntityFilter[];
|
||||
primary_entities_only?: boolean;
|
||||
} | null;
|
||||
}
|
||||
>;
|
||||
|
||||
// Like core, the components with a target that apply to at least one entity
|
||||
// of the target. A target without entity filters applies to any entity.
|
||||
const componentsForTarget = (
|
||||
hass: MockHomeAssistant,
|
||||
target: HassServiceTarget,
|
||||
descriptions: TargetDescriptions
|
||||
) => {
|
||||
const entityIds = expandTarget(hass, target, false).entities;
|
||||
const targetedIds = ensureArray(target.entity_id ?? []);
|
||||
return Object.entries(descriptions)
|
||||
.filter(([, description]) => {
|
||||
if (!description.target) {
|
||||
return false;
|
||||
}
|
||||
const filters = ensureArray(description.target.entity ?? []);
|
||||
const primaryEntitiesOnly =
|
||||
description.target.primary_entities_only ?? true;
|
||||
return entityIds.some(
|
||||
(entityId) =>
|
||||
// Entities with a category only count when targeted directly,
|
||||
// unless the component allows them
|
||||
(!primaryEntitiesOnly ||
|
||||
targetedIds.includes(entityId) ||
|
||||
!hass.entities[entityId]?.entity_category) &&
|
||||
(!filters.length ||
|
||||
filters.some((filter) => matchesFilter(hass, entityId, filter)))
|
||||
);
|
||||
})
|
||||
.map(([key]) => key);
|
||||
};
|
||||
|
||||
const mockForTarget = (
|
||||
hass: MockHomeAssistant,
|
||||
type: string,
|
||||
descriptions: Descriptions
|
||||
) =>
|
||||
hass.mockWS(
|
||||
type,
|
||||
(msg: { target: HassServiceTarget }, currentHass: MockHomeAssistant) =>
|
||||
componentsForTarget(
|
||||
currentHass,
|
||||
msg.target,
|
||||
descriptions as TargetDescriptions
|
||||
)
|
||||
);
|
||||
|
||||
const mockPlatformSubscription = (
|
||||
hass: MockHomeAssistant,
|
||||
type: string,
|
||||
descriptions: Descriptions
|
||||
) =>
|
||||
hass.mockWS(
|
||||
type,
|
||||
(_msg, _hass, onChange?: (descriptions: Descriptions) => void) => {
|
||||
onChange?.(descriptions);
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
|
||||
export const mockAutomationPlatforms = (hass: MockHomeAssistant) => {
|
||||
mockPlatformSubscription(
|
||||
hass,
|
||||
"trigger_platforms/subscribe",
|
||||
triggerDescriptions
|
||||
);
|
||||
mockPlatformSubscription(
|
||||
hass,
|
||||
"condition_platforms/subscribe",
|
||||
conditionDescriptions
|
||||
);
|
||||
mockForTarget(hass, "get_triggers_for_target", triggerDescriptions);
|
||||
mockForTarget(hass, "get_conditions_for_target", conditionDescriptions);
|
||||
hass.mockWS(
|
||||
"extract_from_target",
|
||||
(
|
||||
msg: { target: HassServiceTarget; primary_entities_only?: boolean },
|
||||
currentHass: MockHomeAssistant
|
||||
): ExtractFromTargetResult => {
|
||||
const { areas, devices, entities, missing } = expandTarget(
|
||||
currentHass,
|
||||
msg.target,
|
||||
msg.primary_entities_only ?? true
|
||||
);
|
||||
return {
|
||||
missing_areas: missing.areas,
|
||||
missing_devices: missing.devices,
|
||||
missing_floors: missing.floors,
|
||||
missing_labels: missing.labels,
|
||||
referenced_areas: areas,
|
||||
referenced_devices: devices,
|
||||
referenced_entities: entities,
|
||||
};
|
||||
}
|
||||
);
|
||||
hass.mockWS(
|
||||
"get_services_for_target",
|
||||
(msg: { target: HassServiceTarget }, currentHass: MockHomeAssistant) =>
|
||||
componentsForTarget(
|
||||
currentHass,
|
||||
msg.target,
|
||||
// Keyed by <domain>.<service>, like the triggers and conditions
|
||||
Object.fromEntries(
|
||||
Object.entries(currentHass.services).flatMap(([domain, services]) =>
|
||||
Object.entries(services).map(([service, description]) => [
|
||||
`${domain}.${service}`,
|
||||
description,
|
||||
])
|
||||
)
|
||||
) as TargetDescriptions
|
||||
)
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,495 @@
|
||||
// This file is auto-generated from Home Assistant Core. Do not edit by hand.
|
||||
// Regenerate with `script/gen_demo_core_data`.
|
||||
|
||||
export const triggerIcons = {
|
||||
air_quality: {
|
||||
co2_changed: { trigger: "mdi:molecule-co2" },
|
||||
co2_crossed_threshold: { trigger: "mdi:molecule-co2" },
|
||||
co_changed: { trigger: "mdi:molecule-co" },
|
||||
co_cleared: { trigger: "mdi:check-circle" },
|
||||
co_crossed_threshold: { trigger: "mdi:molecule-co" },
|
||||
co_detected: { trigger: "mdi:molecule-co" },
|
||||
gas_cleared: { trigger: "mdi:check-circle" },
|
||||
gas_detected: { trigger: "mdi:gas-cylinder" },
|
||||
n2o_changed: { trigger: "mdi:factory" },
|
||||
n2o_crossed_threshold: { trigger: "mdi:factory" },
|
||||
no2_changed: { trigger: "mdi:factory" },
|
||||
no2_crossed_threshold: { trigger: "mdi:factory" },
|
||||
no_changed: { trigger: "mdi:factory" },
|
||||
no_crossed_threshold: { trigger: "mdi:factory" },
|
||||
ozone_changed: { trigger: "mdi:weather-sunny-alert" },
|
||||
ozone_crossed_threshold: { trigger: "mdi:weather-sunny-alert" },
|
||||
pm10_changed: { trigger: "mdi:blur" },
|
||||
pm10_crossed_threshold: { trigger: "mdi:blur" },
|
||||
pm1_changed: { trigger: "mdi:blur" },
|
||||
pm1_crossed_threshold: { trigger: "mdi:blur" },
|
||||
pm25_changed: { trigger: "mdi:blur" },
|
||||
pm25_crossed_threshold: { trigger: "mdi:blur" },
|
||||
pm4_changed: { trigger: "mdi:blur" },
|
||||
pm4_crossed_threshold: { trigger: "mdi:blur" },
|
||||
smoke_cleared: { trigger: "mdi:check-circle" },
|
||||
smoke_detected: { trigger: "mdi:smoke-detector-variant" },
|
||||
so2_changed: { trigger: "mdi:factory" },
|
||||
so2_crossed_threshold: { trigger: "mdi:factory" },
|
||||
voc_changed: { trigger: "mdi:air-filter" },
|
||||
voc_crossed_threshold: { trigger: "mdi:air-filter" },
|
||||
voc_ratio_changed: { trigger: "mdi:air-filter" },
|
||||
voc_ratio_crossed_threshold: { trigger: "mdi:air-filter" },
|
||||
},
|
||||
alarm_control_panel: {
|
||||
armed: { trigger: "mdi:shield" },
|
||||
armed_away: { trigger: "mdi:shield-lock" },
|
||||
armed_home: { trigger: "mdi:shield-home" },
|
||||
armed_night: { trigger: "mdi:shield-moon" },
|
||||
armed_vacation: { trigger: "mdi:shield-airplane" },
|
||||
disarmed: { trigger: "mdi:shield-off" },
|
||||
triggered: { trigger: "mdi:bell-ring" },
|
||||
},
|
||||
assist_satellite: {
|
||||
idle: { trigger: "mdi:chat-sleep" },
|
||||
listening: { trigger: "mdi:chat-question" },
|
||||
processing: { trigger: "mdi:chat-processing" },
|
||||
responding: { trigger: "mdi:chat-alert" },
|
||||
},
|
||||
battery: {
|
||||
became_low: { trigger: "mdi:battery-alert" },
|
||||
level_changed: { trigger: "mdi:battery-unknown" },
|
||||
level_crossed_threshold: { trigger: "mdi:battery-alert" },
|
||||
no_longer_low: { trigger: "mdi:battery" },
|
||||
started_charging: { trigger: "mdi:battery-charging" },
|
||||
stopped_charging: { trigger: "mdi:battery" },
|
||||
},
|
||||
button: { pressed: { trigger: "mdi:gesture-tap-button" } },
|
||||
calendar: {
|
||||
event_ended: { trigger: "mdi:calendar-end" },
|
||||
event_started: { trigger: "mdi:calendar-start" },
|
||||
},
|
||||
climate: {
|
||||
hvac_mode_changed: { trigger: "mdi:thermostat" },
|
||||
started_cooling: { trigger: "mdi:snowflake" },
|
||||
started_drying: { trigger: "mdi:water-percent" },
|
||||
started_heating: { trigger: "mdi:fire" },
|
||||
target_humidity_changed: { trigger: "mdi:water-percent" },
|
||||
target_humidity_crossed_threshold: { trigger: "mdi:water-percent" },
|
||||
target_temperature_changed: { trigger: "mdi:thermometer" },
|
||||
target_temperature_crossed_threshold: { trigger: "mdi:thermometer" },
|
||||
turned_off: { trigger: "mdi:power-off" },
|
||||
turned_on: { trigger: "mdi:power-on" },
|
||||
},
|
||||
counter: {
|
||||
decremented: { trigger: "mdi:numeric-negative-1" },
|
||||
incremented: { trigger: "mdi:numeric-positive-1" },
|
||||
maximum_reached: { trigger: "mdi:sort-numeric-ascending-variant" },
|
||||
minimum_reached: { trigger: "mdi:sort-numeric-descending-variant" },
|
||||
reset: { trigger: "mdi:refresh" },
|
||||
},
|
||||
cover: {
|
||||
awning_closed: { trigger: "mdi:storefront-outline" },
|
||||
awning_opened: { trigger: "mdi:storefront-outline" },
|
||||
blind_closed: { trigger: "mdi:blinds-horizontal-closed" },
|
||||
blind_opened: { trigger: "mdi:blinds-horizontal" },
|
||||
curtain_closed: { trigger: "mdi:curtains-closed" },
|
||||
curtain_opened: { trigger: "mdi:curtains" },
|
||||
shade_closed: { trigger: "mdi:roller-shade-closed" },
|
||||
shade_opened: { trigger: "mdi:roller-shade" },
|
||||
shutter_closed: { trigger: "mdi:window-shutter" },
|
||||
shutter_opened: { trigger: "mdi:window-shutter-open" },
|
||||
},
|
||||
door: {
|
||||
closed: { trigger: "mdi:door-closed" },
|
||||
opened: { trigger: "mdi:door-open" },
|
||||
},
|
||||
doorbell: { rang: { trigger: "mdi:doorbell" } },
|
||||
event: { received: { trigger: "mdi:eye-check" } },
|
||||
fan: {
|
||||
turned_off: { trigger: "mdi:fan-off" },
|
||||
turned_on: { trigger: "mdi:fan" },
|
||||
},
|
||||
garage_door: {
|
||||
closed: { trigger: "mdi:garage" },
|
||||
opened: { trigger: "mdi:garage-open" },
|
||||
},
|
||||
gate: {
|
||||
closed: { trigger: "mdi:gate" },
|
||||
opened: { trigger: "mdi:gate-open" },
|
||||
},
|
||||
humidifier: {
|
||||
mode_changed: { trigger: "mdi:air-humidifier" },
|
||||
started_drying: { trigger: "mdi:arrow-down-bold" },
|
||||
started_humidifying: { trigger: "mdi:arrow-up-bold" },
|
||||
turned_off: { trigger: "mdi:air-humidifier-off" },
|
||||
turned_on: { trigger: "mdi:air-humidifier" },
|
||||
},
|
||||
humidity: {
|
||||
changed: { trigger: "mdi:water-percent" },
|
||||
crossed_threshold: { trigger: "mdi:water-percent" },
|
||||
},
|
||||
illuminance: {
|
||||
changed: { trigger: "mdi:brightness-6" },
|
||||
cleared: { trigger: "mdi:brightness-4" },
|
||||
crossed_threshold: { trigger: "mdi:brightness-6" },
|
||||
detected: { trigger: "mdi:brightness-7" },
|
||||
},
|
||||
lawn_mower: {
|
||||
became_idle: { trigger: "mdi:stop" },
|
||||
errored: { trigger: "mdi:alert-circle-outline" },
|
||||
paused_mowing: { trigger: "mdi:pause" },
|
||||
returned_to_dock: { trigger: "mdi:home-import-outline" },
|
||||
started_mowing: { trigger: "mdi:play" },
|
||||
started_returning: { trigger: "mdi:home-import-outline" },
|
||||
},
|
||||
light: {
|
||||
brightness_changed: { trigger: "mdi:lightbulb-on-50" },
|
||||
brightness_crossed_threshold: { trigger: "mdi:lightbulb-on-50" },
|
||||
turned_off: { trigger: "mdi:lightbulb-off" },
|
||||
turned_on: { trigger: "mdi:lightbulb-on" },
|
||||
},
|
||||
lock: {
|
||||
jammed: { trigger: "mdi:lock-alert" },
|
||||
locked: { trigger: "mdi:lock" },
|
||||
opened: { trigger: "mdi:lock-open-variant" },
|
||||
unlocked: { trigger: "mdi:lock-open-variant" },
|
||||
},
|
||||
media_player: {
|
||||
muted: { trigger: "mdi:volume-mute" },
|
||||
paused_playing: { trigger: "mdi:pause" },
|
||||
started_playing: { trigger: "mdi:play" },
|
||||
stopped_playing: { trigger: "mdi:stop" },
|
||||
turned_off: { trigger: "mdi:power" },
|
||||
turned_on: { trigger: "mdi:power" },
|
||||
unmuted: { trigger: "mdi:volume-high" },
|
||||
volume_changed: { trigger: "mdi:volume-medium" },
|
||||
volume_crossed_threshold: { trigger: "mdi:volume-medium" },
|
||||
},
|
||||
moisture: {
|
||||
changed: { trigger: "mdi:water-percent" },
|
||||
cleared: { trigger: "mdi:water-off" },
|
||||
crossed_threshold: { trigger: "mdi:water-percent" },
|
||||
detected: { trigger: "mdi:water" },
|
||||
},
|
||||
moon: { phase_changed: { trigger: "mdi:moon-waning-crescent" } },
|
||||
motion: {
|
||||
cleared: { trigger: "mdi:motion-sensor-off" },
|
||||
detected: { trigger: "mdi:motion-sensor" },
|
||||
},
|
||||
occupancy: {
|
||||
cleared: { trigger: "mdi:home-outline" },
|
||||
detected: { trigger: "mdi:home-account" },
|
||||
},
|
||||
power: {
|
||||
changed: { trigger: "mdi:flash" },
|
||||
crossed_threshold: { trigger: "mdi:flash" },
|
||||
},
|
||||
remote: {
|
||||
turned_off: { trigger: "mdi:remote-off" },
|
||||
turned_on: { trigger: "mdi:remote" },
|
||||
},
|
||||
scene: { activated: { trigger: "mdi:palette" } },
|
||||
schedule: {
|
||||
block_ended: { trigger: "mdi:calendar-blank" },
|
||||
block_started: { trigger: "mdi:calendar-clock" },
|
||||
},
|
||||
select: { selection_changed: { trigger: "mdi:format-list-bulleted" } },
|
||||
siren: {
|
||||
turned_off: { trigger: "mdi:bullhorn-outline" },
|
||||
turned_on: { trigger: "mdi:bullhorn" },
|
||||
},
|
||||
sun: {
|
||||
blue_hour_ended: { trigger: "mdi:weather-sunset" },
|
||||
blue_hour_started: { trigger: "mdi:weather-sunset" },
|
||||
dawn: { trigger: "mdi:weather-sunset-up" },
|
||||
dusk: { trigger: "mdi:weather-sunset-down" },
|
||||
elevation_changed: { trigger: "mdi:sun-angle" },
|
||||
elevation_crossed_threshold: { trigger: "mdi:sun-angle" },
|
||||
golden_hour_ended: { trigger: "mdi:weather-sunset" },
|
||||
golden_hour_started: { trigger: "mdi:weather-sunset" },
|
||||
midnight_sun_ended: { trigger: "mdi:weather-sunset" },
|
||||
midnight_sun_started: { trigger: "mdi:weather-sunny" },
|
||||
polar_night_ended: { trigger: "mdi:weather-sunset-up" },
|
||||
polar_night_started: { trigger: "mdi:weather-night" },
|
||||
solar_midnight: { trigger: "mdi:weather-night" },
|
||||
solar_noon: { trigger: "mdi:weather-sunny" },
|
||||
sunrise: { trigger: "mdi:weather-sunset-up" },
|
||||
sunset: { trigger: "mdi:weather-sunset-down" },
|
||||
},
|
||||
switch: {
|
||||
turned_off: { trigger: "mdi:toggle-switch-variant-off" },
|
||||
turned_on: { trigger: "mdi:toggle-switch-variant" },
|
||||
},
|
||||
temperature: {
|
||||
changed: { trigger: "mdi:thermometer" },
|
||||
crossed_threshold: { trigger: "mdi:thermometer" },
|
||||
},
|
||||
text: { changed: { trigger: "mdi:form-textbox" } },
|
||||
timer: {
|
||||
cancelled: { trigger: "mdi:timer-cancel" },
|
||||
finished: { trigger: "mdi:timer-check" },
|
||||
paused: { trigger: "mdi:timer-pause" },
|
||||
remaining_time_reached: { trigger: "mdi:timer-alert-outline" },
|
||||
restarted: { trigger: "mdi:timer-refresh" },
|
||||
started: { trigger: "mdi:timer-play" },
|
||||
},
|
||||
todo: {
|
||||
item_added: { trigger: "mdi:clipboard-plus" },
|
||||
item_completed: { trigger: "mdi:clipboard-check" },
|
||||
item_removed: { trigger: "mdi:clipboard-minus" },
|
||||
},
|
||||
update: { became_available: { trigger: "mdi:package-up" } },
|
||||
vacuum: {
|
||||
errored: { trigger: "mdi:alert-circle-outline" },
|
||||
paused_cleaning: { trigger: "mdi:pause" },
|
||||
returned_to_dock: { trigger: "mdi:home-outline" },
|
||||
started_cleaning: { trigger: "mdi:play" },
|
||||
started_returning: { trigger: "mdi:home-import-outline" },
|
||||
},
|
||||
valve: {
|
||||
closed: { trigger: "mdi:valve-closed" },
|
||||
opened: { trigger: "mdi:valve-open" },
|
||||
},
|
||||
vibration: {
|
||||
cleared: { trigger: "mdi:vibrate-off" },
|
||||
detected: { trigger: "mdi:vibrate" },
|
||||
},
|
||||
water_heater: {
|
||||
operation_mode_changed: { trigger: "mdi:water-boiler" },
|
||||
target_temperature_changed: { trigger: "mdi:thermometer" },
|
||||
target_temperature_crossed_threshold: { trigger: "mdi:thermometer" },
|
||||
turned_off: { trigger: "mdi:water-boiler-off" },
|
||||
turned_on: { trigger: "mdi:water-boiler" },
|
||||
},
|
||||
window: {
|
||||
closed: { trigger: "mdi:window-closed" },
|
||||
opened: { trigger: "mdi:window-open" },
|
||||
},
|
||||
zone: {
|
||||
entered: { trigger: "mdi:map-marker-plus" },
|
||||
left: { trigger: "mdi:map-marker-minus" },
|
||||
occupancy_cleared: { trigger: "mdi:account-off" },
|
||||
occupancy_detected: { trigger: "mdi:account-group" },
|
||||
},
|
||||
};
|
||||
|
||||
export const conditionIcons = {
|
||||
air_quality: {
|
||||
is_co2_value: { condition: "mdi:molecule-co2" },
|
||||
is_co_cleared: { condition: "mdi:check-circle" },
|
||||
is_co_detected: { condition: "mdi:molecule-co" },
|
||||
is_co_value: { condition: "mdi:molecule-co" },
|
||||
is_gas_cleared: { condition: "mdi:check-circle" },
|
||||
is_gas_detected: { condition: "mdi:gas-cylinder" },
|
||||
is_n2o_value: { condition: "mdi:factory" },
|
||||
is_no2_value: { condition: "mdi:factory" },
|
||||
is_no_value: { condition: "mdi:factory" },
|
||||
is_ozone_value: { condition: "mdi:weather-sunny-alert" },
|
||||
is_pm10_value: { condition: "mdi:blur" },
|
||||
is_pm1_value: { condition: "mdi:blur" },
|
||||
is_pm25_value: { condition: "mdi:blur" },
|
||||
is_pm4_value: { condition: "mdi:blur" },
|
||||
is_smoke_cleared: { condition: "mdi:check-circle" },
|
||||
is_smoke_detected: { condition: "mdi:smoke-detector-variant" },
|
||||
is_so2_value: { condition: "mdi:factory" },
|
||||
is_voc_ratio_value: { condition: "mdi:air-filter" },
|
||||
is_voc_value: { condition: "mdi:air-filter" },
|
||||
},
|
||||
alarm_control_panel: {
|
||||
is_armed: { condition: "mdi:shield" },
|
||||
is_armed_away: { condition: "mdi:shield-lock" },
|
||||
is_armed_home: { condition: "mdi:shield-home" },
|
||||
is_armed_night: { condition: "mdi:shield-moon" },
|
||||
is_armed_vacation: { condition: "mdi:shield-airplane" },
|
||||
is_disarmed: { condition: "mdi:shield-off" },
|
||||
is_triggered: { condition: "mdi:bell-ring" },
|
||||
},
|
||||
assist_satellite: {
|
||||
is_idle: { condition: "mdi:chat-sleep" },
|
||||
is_listening: { condition: "mdi:chat-question" },
|
||||
is_processing: { condition: "mdi:chat-processing" },
|
||||
is_responding: { condition: "mdi:chat-alert" },
|
||||
},
|
||||
battery: {
|
||||
is_charging: { condition: "mdi:battery-charging" },
|
||||
is_level: { condition: "mdi:battery-unknown" },
|
||||
is_low: { condition: "mdi:battery-alert" },
|
||||
is_not_charging: { condition: "mdi:battery" },
|
||||
is_not_low: { condition: "mdi:battery" },
|
||||
},
|
||||
calendar: { is_event_active: { condition: "mdi:calendar-check" } },
|
||||
climate: {
|
||||
is_cooling: { condition: "mdi:snowflake" },
|
||||
is_drying: { condition: "mdi:water-percent" },
|
||||
is_heating: { condition: "mdi:fire" },
|
||||
is_hvac_mode: { condition: "mdi:thermostat" },
|
||||
is_off: { condition: "mdi:power-off" },
|
||||
is_on: { condition: "mdi:power-on" },
|
||||
is_target_humidity: { condition: "mdi:water-percent" },
|
||||
is_target_temperature: { condition: "mdi:thermometer" },
|
||||
},
|
||||
counter: { is_value: { condition: "mdi:counter" } },
|
||||
cover: {
|
||||
awning_is_closed: { condition: "mdi:storefront-outline" },
|
||||
awning_is_open: { condition: "mdi:storefront-outline" },
|
||||
blind_is_closed: { condition: "mdi:blinds-horizontal-closed" },
|
||||
blind_is_open: { condition: "mdi:blinds-horizontal" },
|
||||
curtain_is_closed: { condition: "mdi:curtains-closed" },
|
||||
curtain_is_open: { condition: "mdi:curtains" },
|
||||
shade_is_closed: { condition: "mdi:roller-shade-closed" },
|
||||
shade_is_open: { condition: "mdi:roller-shade" },
|
||||
shutter_is_closed: { condition: "mdi:window-shutter" },
|
||||
shutter_is_open: { condition: "mdi:window-shutter-open" },
|
||||
},
|
||||
door: {
|
||||
is_closed: { condition: "mdi:door-closed" },
|
||||
is_open: { condition: "mdi:door-open" },
|
||||
},
|
||||
fan: {
|
||||
is_off: { condition: "mdi:fan-off" },
|
||||
is_on: { condition: "mdi:fan" },
|
||||
},
|
||||
garage_door: {
|
||||
is_closed: { condition: "mdi:garage" },
|
||||
is_open: { condition: "mdi:garage-open" },
|
||||
},
|
||||
gate: {
|
||||
is_closed: { condition: "mdi:gate" },
|
||||
is_open: { condition: "mdi:gate-open" },
|
||||
},
|
||||
humidifier: {
|
||||
is_drying: { condition: "mdi:arrow-down-bold" },
|
||||
is_humidifying: { condition: "mdi:arrow-up-bold" },
|
||||
is_mode: { condition: "mdi:air-humidifier" },
|
||||
is_off: { condition: "mdi:air-humidifier-off" },
|
||||
is_on: { condition: "mdi:air-humidifier" },
|
||||
is_target_humidity: { condition: "mdi:water-percent" },
|
||||
},
|
||||
humidity: { is_value: { condition: "mdi:water-percent" } },
|
||||
illuminance: {
|
||||
is_detected: { condition: "mdi:brightness-7" },
|
||||
is_not_detected: { condition: "mdi:brightness-4" },
|
||||
is_value: { condition: "mdi:brightness-percent" },
|
||||
},
|
||||
lawn_mower: {
|
||||
is_docked: { condition: "mdi:home-import-outline" },
|
||||
is_encountering_an_error: { condition: "mdi:alert-circle-outline" },
|
||||
is_idle: { condition: "mdi:stop" },
|
||||
is_mowing: { condition: "mdi:play" },
|
||||
is_paused: { condition: "mdi:pause" },
|
||||
is_returning: { condition: "mdi:home-import-outline" },
|
||||
},
|
||||
light: {
|
||||
is_brightness: { condition: "mdi:lightbulb-on-50" },
|
||||
is_off: { condition: "mdi:lightbulb-off" },
|
||||
is_on: { condition: "mdi:lightbulb-on" },
|
||||
},
|
||||
lock: {
|
||||
is_jammed: { condition: "mdi:lock-alert" },
|
||||
is_locked: { condition: "mdi:lock" },
|
||||
is_open: { condition: "mdi:lock-open-variant" },
|
||||
is_unlocked: { condition: "mdi:lock-open-variant" },
|
||||
},
|
||||
media_player: {
|
||||
is_muted: { condition: "mdi:volume-mute" },
|
||||
is_not_playing: { condition: "mdi:stop" },
|
||||
is_off: { condition: "mdi:cast-off" },
|
||||
is_on: { condition: "mdi:cast" },
|
||||
is_paused: { condition: "mdi:pause" },
|
||||
is_playing: { condition: "mdi:play" },
|
||||
is_unmuted: { condition: "mdi:volume-high" },
|
||||
is_volume: { condition: "mdi:volume-medium" },
|
||||
},
|
||||
moisture: {
|
||||
is_detected: { condition: "mdi:water" },
|
||||
is_not_detected: { condition: "mdi:water-off" },
|
||||
is_value: { condition: "mdi:water-percent" },
|
||||
},
|
||||
moon: {
|
||||
is_phase: { condition: "mdi:moon-full" },
|
||||
is_waning: { condition: "mdi:moon-waning-crescent" },
|
||||
is_waxing: { condition: "mdi:moon-waxing-crescent" },
|
||||
},
|
||||
motion: {
|
||||
is_detected: { condition: "mdi:motion-sensor" },
|
||||
is_not_detected: { condition: "mdi:motion-sensor-off" },
|
||||
},
|
||||
occupancy: {
|
||||
is_detected: { condition: "mdi:home-account" },
|
||||
is_not_detected: { condition: "mdi:home-outline" },
|
||||
},
|
||||
power: { is_value: { condition: "mdi:flash" } },
|
||||
remote: {
|
||||
is_off: { condition: "mdi:remote-off" },
|
||||
is_on: { condition: "mdi:remote" },
|
||||
},
|
||||
schedule: {
|
||||
is_off: { condition: "mdi:calendar-blank" },
|
||||
is_on: { condition: "mdi:calendar-clock" },
|
||||
},
|
||||
select: { is_option_selected: { condition: "mdi:format-list-bulleted" } },
|
||||
siren: {
|
||||
is_off: { condition: "mdi:bullhorn-outline" },
|
||||
is_on: { condition: "mdi:bullhorn" },
|
||||
},
|
||||
sun: {
|
||||
elevation: { condition: "mdi:sun-angle" },
|
||||
is_ascending: { condition: "mdi:weather-sunset-up" },
|
||||
is_blue_hour: { condition: "mdi:weather-sunset" },
|
||||
is_descending: { condition: "mdi:weather-sunset-down" },
|
||||
is_evening_twilight: { condition: "mdi:weather-sunset-down" },
|
||||
is_golden_hour: { condition: "mdi:weather-sunset" },
|
||||
is_midnight_sun: { condition: "mdi:weather-sunny" },
|
||||
is_morning_twilight: { condition: "mdi:weather-sunset-up" },
|
||||
is_night: { condition: "mdi:weather-night" },
|
||||
is_polar_night: { condition: "mdi:weather-night" },
|
||||
is_set: { condition: "mdi:weather-sunny-off" },
|
||||
is_up: { condition: "mdi:weather-sunny" },
|
||||
},
|
||||
switch: {
|
||||
is_off: { condition: "mdi:toggle-switch-variant-off" },
|
||||
is_on: { condition: "mdi:toggle-switch-variant" },
|
||||
},
|
||||
temperature: { is_value: { condition: "mdi:thermometer" } },
|
||||
text: { is_equal_to: { condition: "mdi:form-textbox" } },
|
||||
timer: {
|
||||
is_active: { condition: "mdi:timer" },
|
||||
is_idle: { condition: "mdi:timer-off" },
|
||||
is_paused: { condition: "mdi:timer-pause" },
|
||||
},
|
||||
todo: {
|
||||
all_completed: { condition: "mdi:clipboard-check" },
|
||||
incomplete: { condition: "mdi:clipboard-alert" },
|
||||
},
|
||||
update: {
|
||||
is_available: { condition: "mdi:package-up" },
|
||||
is_not_available: { condition: "mdi:package" },
|
||||
},
|
||||
vacuum: {
|
||||
is_cleaning: { condition: "mdi:play" },
|
||||
is_docked: { condition: "mdi:home-import-outline" },
|
||||
is_encountering_an_error: { condition: "mdi:alert-circle-outline" },
|
||||
is_paused: { condition: "mdi:pause" },
|
||||
is_returning: { condition: "mdi:home-import-outline" },
|
||||
},
|
||||
valve: {
|
||||
is_closed: { condition: "mdi:valve-closed" },
|
||||
is_open: { condition: "mdi:valve-open" },
|
||||
},
|
||||
vibration: {
|
||||
is_detected: { condition: "mdi:vibrate" },
|
||||
is_not_detected: { condition: "mdi:vibrate-off" },
|
||||
},
|
||||
water_heater: {
|
||||
is_off: { condition: "mdi:water-boiler-off" },
|
||||
is_on: { condition: "mdi:water-boiler" },
|
||||
is_operation_mode: { condition: "mdi:water-boiler" },
|
||||
is_target_temperature: { condition: "mdi:thermometer" },
|
||||
},
|
||||
window: {
|
||||
is_closed: { condition: "mdi:window-closed" },
|
||||
is_open: { condition: "mdi:window-open" },
|
||||
},
|
||||
zone: {
|
||||
in_zone: { condition: "mdi:map-marker-check" },
|
||||
not_in_zone: { condition: "mdi:map-marker-remove" },
|
||||
occupancy_is_detected: { condition: "mdi:account-group" },
|
||||
occupancy_is_not_detected: { condition: "mdi:account-off" },
|
||||
},
|
||||
};
|
||||
@@ -39,6 +39,7 @@ const backupInfo: BackupInfo = {
|
||||
},
|
||||
],
|
||||
agent_errors: {},
|
||||
agent_error_translations: {},
|
||||
last_attempted_automatic_backup: recent,
|
||||
last_completed_automatic_backup: recent,
|
||||
last_action_event: { manager_state: "idle" },
|
||||
|
||||
@@ -12,6 +12,7 @@ import { mockDeviceAutomation } from "./device_automation";
|
||||
import { mockEntityRegistrySettings } from "./entity_registry_settings";
|
||||
import { mockEntitySources } from "./entity_sources";
|
||||
import { mockExpose } from "./expose";
|
||||
import { mockMarketplace } from "./marketplace";
|
||||
import { mockNetwork } from "./network";
|
||||
import { mockPerson } from "./person";
|
||||
import { mockScene } from "./scene";
|
||||
@@ -45,6 +46,7 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
|
||||
mockAssist(hass);
|
||||
mockEntityRegistrySettings(hass);
|
||||
mockSlugify(hass);
|
||||
mockMarketplace(hass);
|
||||
hass.mockWS("llm/api/list", () => ({
|
||||
apis: [
|
||||
{ id: "assist", name: "Assist" },
|
||||
|
||||
@@ -91,6 +91,15 @@ export const demoConfigEntries: {
|
||||
supports_options: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "system",
|
||||
entry: {
|
||||
...baseEntry,
|
||||
entry_id: "mock-marketplace",
|
||||
domain: "marketplace",
|
||||
title: "Marketplace",
|
||||
},
|
||||
},
|
||||
...connectivityConfigEntries,
|
||||
];
|
||||
|
||||
|
||||
@@ -1,53 +1,3 @@
|
||||
export const mapEntities = () =>
|
||||
Object.values({
|
||||
"zone.home": {
|
||||
entity_id: "zone.home",
|
||||
state: "zoning",
|
||||
attributes: {
|
||||
hidden: true,
|
||||
latitude: 52.3631339,
|
||||
longitude: 4.8903147,
|
||||
radius: 200,
|
||||
friendly_name: "Home",
|
||||
icon: "hademo:home",
|
||||
},
|
||||
},
|
||||
"zone.uva": {
|
||||
entity_id: "zone.buckhead",
|
||||
state: "zoning",
|
||||
attributes: {
|
||||
hidden: true,
|
||||
radius: 400,
|
||||
friendly_name: "UvA",
|
||||
icon: "hademo:school",
|
||||
latitude: 52.3558182,
|
||||
longitude: 4.9535376,
|
||||
},
|
||||
},
|
||||
"person.arsaboo": {
|
||||
entity_id: "person.arsaboo",
|
||||
state: "not_home",
|
||||
attributes: {
|
||||
radius: 50,
|
||||
friendly_name: "Arsaboo",
|
||||
latitude: 52.3579946,
|
||||
longitude: 4.8664597,
|
||||
entity_picture: "/assets/arsaboo/images/arsaboo.jpg",
|
||||
},
|
||||
},
|
||||
"person.melody": {
|
||||
entity_id: "person.melody",
|
||||
state: "not_home",
|
||||
attributes: {
|
||||
radius: 50,
|
||||
friendly_name: "Melody",
|
||||
latitude: 52.3408927,
|
||||
longitude: 4.8711073,
|
||||
entity_picture: "/assets/arsaboo/images/melody.jpg",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const energyEntities = () =>
|
||||
Object.values({
|
||||
"sensor.grid_fossil_fuel_percentage": {
|
||||
|
||||
@@ -1,25 +1,244 @@
|
||||
import type {
|
||||
EntityRegistryDisplayEntryResponse,
|
||||
EntityRegistryEntry,
|
||||
ExtEntityRegistryEntry,
|
||||
UpdateEntityRegistryEntryResult,
|
||||
} from "../../../src/data/entity/entity_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
let entries: EntityRegistryEntry[] = [];
|
||||
|
||||
const newEntry = (
|
||||
entry: Pick<EntityRegistryEntry, "entity_id" | "platform" | "unique_id">
|
||||
): EntityRegistryEntry => {
|
||||
const now = Date.now() / 1000;
|
||||
return {
|
||||
config_entry_id: null,
|
||||
config_subentry_id: null,
|
||||
device_id: null,
|
||||
area_id: null,
|
||||
disabled_by: null,
|
||||
id: entry.entity_id,
|
||||
name: null,
|
||||
icon: null,
|
||||
labels: [],
|
||||
categories: {},
|
||||
hidden_by: null,
|
||||
entity_category: null,
|
||||
has_entity_name: false,
|
||||
options: null,
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
...entry,
|
||||
};
|
||||
};
|
||||
|
||||
// The registry settings that the display registry holds. A display entry
|
||||
// without a registry name shows the name of its state.
|
||||
const displayFields = (entry: EntityRegistryEntry) => ({
|
||||
...(entry.name && { name: entry.name }),
|
||||
icon: entry.icon ?? undefined,
|
||||
area_id: entry.area_id ?? undefined,
|
||||
labels: entry.labels,
|
||||
hidden: entry.hidden_by !== null,
|
||||
});
|
||||
|
||||
const extEntry = (entry: EntityRegistryEntry): ExtEntityRegistryEntry => ({
|
||||
...entry,
|
||||
capabilities: {},
|
||||
aliases: [],
|
||||
});
|
||||
|
||||
export const mockEntityRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
data: EntityRegistryEntry[] = []
|
||||
) => {
|
||||
hass.mockWS("config/entity_registry/list", () => data);
|
||||
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_entries",
|
||||
(msg: { entity_ids: string[] }) => {
|
||||
const result: Record<string, ExtEntityRegistryEntry> = {};
|
||||
for (const entityId of msg.entity_ids) {
|
||||
const entry = data.find((e) => e.entity_id === entityId);
|
||||
const entry = entries.find((e) => e.entity_id === entityId);
|
||||
if (entry) {
|
||||
result[entityId] = { ...entry, capabilities: {}, aliases: [] };
|
||||
result[entityId] = extEntry(entry);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
);
|
||||
hass.mockWS(
|
||||
"config/entity_registry/update",
|
||||
(
|
||||
msg: Pick<
|
||||
EntityRegistryEntry,
|
||||
| "entity_id"
|
||||
| "name"
|
||||
| "icon"
|
||||
| "area_id"
|
||||
| "labels"
|
||||
| "categories"
|
||||
| "hidden_by"
|
||||
| "disabled_by"
|
||||
> & { new_entity_id?: string },
|
||||
currentHass: MockHomeAssistant
|
||||
): UpdateEntityRegistryEntryResult => {
|
||||
const {
|
||||
entity_id: entityId,
|
||||
new_entity_id: newEntityId,
|
||||
...updates
|
||||
} = msg;
|
||||
// Renaming would have to move the entity in every mock, so refuse it
|
||||
// instead of reporting a rename that did not happen
|
||||
if (newEntityId && newEntityId !== entityId) {
|
||||
throw new Error("Changing the entity ID is not supported in the demo");
|
||||
}
|
||||
// The demo entities without an entry get one, like any core entity has
|
||||
const entry = {
|
||||
...(entries.find((e) => e.entity_id === entityId) ??
|
||||
newEntry({
|
||||
entity_id: entityId,
|
||||
platform: currentHass.entities[entityId]?.platform ?? "demo",
|
||||
unique_id: entityId,
|
||||
})),
|
||||
...Object.fromEntries(
|
||||
Object.entries(updates).filter(([key]) => key !== "type")
|
||||
),
|
||||
modified_at: Date.now() / 1000,
|
||||
};
|
||||
entries = [...entries.filter((e) => e.entity_id !== entityId), entry];
|
||||
// Core updates the display registry too, only change what was updated
|
||||
const display = currentHass.entities[entityId];
|
||||
if (display) {
|
||||
const fields = displayFields(entry);
|
||||
currentHass.updateHass({
|
||||
entities: {
|
||||
...currentHass.entities,
|
||||
[entityId]: {
|
||||
...display,
|
||||
...("name" in updates && { name: fields.name }),
|
||||
...("icon" in updates && { icon: fields.icon }),
|
||||
...("area_id" in updates && { area_id: fields.area_id }),
|
||||
...("labels" in updates && { labels: fields.labels }),
|
||||
...("hidden_by" in updates && { hidden: fields.hidden }),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
currentHass.mockEvent("entity_registry_updated");
|
||||
return { entity_entry: extEntry(entry) };
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Entries of a platform that were replaced, so their settings are restored
|
||||
// when the entity comes back after switching demos
|
||||
const retiredEntries = new Map<string, EntityRegistryEntry>();
|
||||
|
||||
// Replaces the entries of a platform, like core does for the entities of a
|
||||
// demo config
|
||||
export const setPlatformEntityRegistryEntries = (
|
||||
hass: MockHomeAssistant,
|
||||
platform: string,
|
||||
platformEntries: Pick<EntityRegistryEntry, "entity_id" | "unique_id">[]
|
||||
) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.platform === platform) {
|
||||
retiredEntries.set(`${platform}.${entry.unique_id}`, entry);
|
||||
}
|
||||
}
|
||||
const restored: EntityRegistryEntry[] = [];
|
||||
const platformRegistryEntries = platformEntries.map((entry) => {
|
||||
const retired = retiredEntries.get(`${platform}.${entry.unique_id}`);
|
||||
if (!retired) {
|
||||
return newEntry({ ...entry, platform });
|
||||
}
|
||||
const restoredEntry = { ...retired, entity_id: entry.entity_id };
|
||||
restored.push(restoredEntry);
|
||||
return restoredEntry;
|
||||
});
|
||||
entries = [
|
||||
...entries.filter((e) => e.platform !== platform),
|
||||
...platformRegistryEntries,
|
||||
];
|
||||
// The display registry was rebuilt with the entities, restore its settings
|
||||
if (restored.length) {
|
||||
const entities = { ...hass.entities };
|
||||
for (const entry of restored) {
|
||||
if (entities[entry.entity_id]) {
|
||||
entities[entry.entity_id] = {
|
||||
...entities[entry.entity_id],
|
||||
...displayFields(entry),
|
||||
};
|
||||
}
|
||||
}
|
||||
hass.updateHass({ entities });
|
||||
}
|
||||
hass.mockEvent("entity_registry_updated");
|
||||
};
|
||||
|
||||
export const getEntityRegistryEntry = (entityId: string) =>
|
||||
entries.find((e) => e.entity_id === entityId);
|
||||
|
||||
export const removeEntityRegistryEntry = (
|
||||
hass: MockHomeAssistant,
|
||||
entityId: string
|
||||
) => {
|
||||
entries = entries.filter((e) => e.entity_id !== entityId);
|
||||
hass.mockEvent("entity_registry_updated");
|
||||
};
|
||||
|
||||
// Adds an entry like core does when an integration creates an entity
|
||||
export const addEntityRegistryEntry = (
|
||||
hass: MockHomeAssistant,
|
||||
entry: Pick<EntityRegistryEntry, "entity_id" | "platform" | "unique_id">
|
||||
) => {
|
||||
entries = [
|
||||
...entries.filter((e) => e.entity_id !== entry.entity_id),
|
||||
newEntry(entry),
|
||||
];
|
||||
hass.mockEvent("entity_registry_updated");
|
||||
};
|
||||
|
||||
// The app refetches the display registry on entity_registry_updated events.
|
||||
// Answer with the current display registry, which the demo keeps in hass.
|
||||
export const mockEntityRegistryDisplay = (hass: MockHomeAssistant) =>
|
||||
hass.mockWS(
|
||||
"config/entity_registry/list_for_display",
|
||||
(
|
||||
_msg,
|
||||
currentHass: MockHomeAssistant
|
||||
): EntityRegistryDisplayEntryResponse => {
|
||||
const categories = [
|
||||
...new Set(
|
||||
Object.values(currentHass.entities)
|
||||
.map((entity) => entity.entity_category)
|
||||
.filter((category) => category !== undefined)
|
||||
),
|
||||
];
|
||||
return {
|
||||
entities: Object.values(currentHass.entities).map((entity) => ({
|
||||
ei: entity.entity_id,
|
||||
di: entity.device_id,
|
||||
ai: entity.area_id,
|
||||
np: entity.next_name_part,
|
||||
lb: entity.labels,
|
||||
ec: entity.entity_category
|
||||
? categories.indexOf(entity.entity_category)
|
||||
: undefined,
|
||||
en: entity.name,
|
||||
ic: entity.icon,
|
||||
pl: entity.platform ?? "demo",
|
||||
tk: entity.translation_key,
|
||||
hb: entity.hidden,
|
||||
dp: entity.display_precision,
|
||||
hn: entity.has_entity_name,
|
||||
})),
|
||||
entity_categories: Object.fromEntries(
|
||||
categories.map((category, index) => [index, category])
|
||||
),
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -12,6 +12,17 @@ export const mockIcons = (hass: MockHomeAssistant) => {
|
||||
category: IconCategory;
|
||||
integration?: string;
|
||||
}) => {
|
||||
if (category === "triggers" || category === "conditions") {
|
||||
const { triggerIcons, conditionIcons } =
|
||||
await import("./automation_platforms_icons");
|
||||
const icons: Record<string, unknown> =
|
||||
category === "triggers" ? triggerIcons : conditionIcons;
|
||||
return {
|
||||
resources: integration
|
||||
? { [integration]: icons[integration] ?? {} }
|
||||
: icons,
|
||||
};
|
||||
}
|
||||
if (integration) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { LabelRegistryEntry } from "../../../src/data/label/label_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
let labels: LabelRegistryEntry[] = [];
|
||||
|
||||
export const getLabelIds = () => labels.map((label) => label.label_id);
|
||||
|
||||
export const mockLabelRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
data: LabelRegistryEntry[] = []
|
||||
) => hass.mockWS("config/label_registry/list", () => data);
|
||||
) => {
|
||||
labels = data;
|
||||
hass.mockWS("config/label_registry/list", () => data);
|
||||
};
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import "../custom-cards/cast-demo-row";
|
||||
import "../custom-cards/ha-demo-card";
|
||||
import "../custom-cards/ha-demo-next-card";
|
||||
import { mapEntities } from "./entities";
|
||||
import { mockMap } from "./map";
|
||||
|
||||
export const mockLovelace = (
|
||||
hass: MockHomeAssistant,
|
||||
@@ -17,7 +17,7 @@ export const mockLovelace = (
|
||||
) => {
|
||||
hass.mockWS("lovelace/config", ({ url_path }) => {
|
||||
if (url_path === "map") {
|
||||
hass.addEntities(mapEntities());
|
||||
mockMap(hass);
|
||||
return {
|
||||
strategy: {
|
||||
type: "map",
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
|
||||
import type { EntityHistoryState } from "../../../src/data/history";
|
||||
import type { Zone } from "../../../src/data/zone";
|
||||
import { demoConfig } from "../../../src/fake_data/demo_config";
|
||||
import type { EntityInput } from "../../../src/fake_data/entities/types";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const HOUR = 3600 * 1000;
|
||||
|
||||
type ZoneData = Omit<Zone, "id">;
|
||||
|
||||
const HOME: ZoneData = {
|
||||
name: "Home",
|
||||
icon: "mdi:home",
|
||||
latitude: demoConfig.latitude,
|
||||
longitude: demoConfig.longitude,
|
||||
radius: 100,
|
||||
passive: false,
|
||||
};
|
||||
|
||||
export const zones: Zone[] = [
|
||||
{
|
||||
id: "gym",
|
||||
name: "Gym",
|
||||
icon: "mdi:dumbbell",
|
||||
latitude: 52.358,
|
||||
longitude: 4.8686,
|
||||
radius: 150,
|
||||
passive: false,
|
||||
},
|
||||
{
|
||||
id: "school",
|
||||
name: "School",
|
||||
icon: "mdi:school",
|
||||
latitude: 52.3558182,
|
||||
longitude: 4.9535376,
|
||||
radius: 250,
|
||||
passive: false,
|
||||
},
|
||||
{
|
||||
id: "supermarket",
|
||||
name: "Supermarket",
|
||||
icon: "mdi:cart",
|
||||
latitude: 52.3793,
|
||||
longitude: 4.8994,
|
||||
radius: 100,
|
||||
passive: false,
|
||||
},
|
||||
{
|
||||
id: "work",
|
||||
name: "Work",
|
||||
icon: "mdi:briefcase",
|
||||
latitude: 52.3909184,
|
||||
longitude: 4.8530821,
|
||||
radius: 200,
|
||||
passive: false,
|
||||
},
|
||||
];
|
||||
|
||||
interface DemoPerson {
|
||||
id: string;
|
||||
name: string;
|
||||
picture: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
// Hours ago and the state entered then, oldest first. The first entry is
|
||||
// the state before the 24 hour activity window, the last one the current
|
||||
// state.
|
||||
timeline: [number, string][];
|
||||
}
|
||||
|
||||
const PEOPLE: DemoPerson[] = [
|
||||
{
|
||||
id: "arsaboo",
|
||||
name: "Arsaboo",
|
||||
picture: "/assets/arsaboo/images/arsaboo.jpg",
|
||||
latitude: 52.3732,
|
||||
longitude: 4.8904,
|
||||
timeline: [
|
||||
[30, "home"],
|
||||
[10, "not_home"],
|
||||
[9.5, "Work"],
|
||||
[5, "not_home"],
|
||||
[4.6, "Gym"],
|
||||
[3.4, "not_home"],
|
||||
[3.1, "Supermarket"],
|
||||
[2.8, "not_home"],
|
||||
[2.4, "home"],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "melody",
|
||||
name: "Melody",
|
||||
picture: "/assets/arsaboo/images/melody.jpg",
|
||||
latitude: 52.373,
|
||||
longitude: 4.8901,
|
||||
timeline: [
|
||||
[30, "home"],
|
||||
[9, "not_home"],
|
||||
[8.6, "School"],
|
||||
[4, "not_home"],
|
||||
[3.6, "home"],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "oscar",
|
||||
name: "Oscar",
|
||||
picture: "/assets/kernehed/oscar.jpg",
|
||||
latitude: 52.391,
|
||||
longitude: 4.8532,
|
||||
timeline: [
|
||||
[30, "home"],
|
||||
[9, "not_home"],
|
||||
[8.7, "Work"],
|
||||
[4.5, "not_home"],
|
||||
[4.2, "Supermarket"],
|
||||
[3.8, "not_home"],
|
||||
[3.5, "Work"],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "bella",
|
||||
name: "Bella",
|
||||
picture: "/assets/kernehed/bella.jpg",
|
||||
latitude: 52.365,
|
||||
longitude: 4.8795,
|
||||
timeline: [
|
||||
[30, "home"],
|
||||
[8, "not_home"],
|
||||
[7.6, "Work"],
|
||||
[1.2, "not_home"],
|
||||
[0.9, "Gym"],
|
||||
[0.3, "not_home"],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const CAR: EntityInput = {
|
||||
entity_id: "device_tracker.car",
|
||||
state: "not_home",
|
||||
attributes: {
|
||||
friendly_name: "Car",
|
||||
icon: "mdi:car",
|
||||
source_type: "gps",
|
||||
latitude: 52.3805,
|
||||
longitude: 4.8718,
|
||||
gps_accuracy: 10,
|
||||
},
|
||||
};
|
||||
|
||||
const lastMove = (person: DemoPerson) =>
|
||||
person.timeline[person.timeline.length - 1];
|
||||
|
||||
const zoneEntity = (entityId: string, zone: ZoneData): EntityInput => {
|
||||
// A person's state is "home" for the home zone, the zone name otherwise
|
||||
const personState = entityId === "zone.home" ? "home" : zone.name;
|
||||
const persons = PEOPLE.filter(
|
||||
(person) => lastMove(person)[1] === personState
|
||||
).map((person) => `person.${person.id}`);
|
||||
return {
|
||||
entity_id: entityId,
|
||||
state: String(persons.length),
|
||||
attributes: {
|
||||
latitude: zone.latitude,
|
||||
longitude: zone.longitude,
|
||||
radius: zone.radius,
|
||||
passive: zone.passive,
|
||||
persons,
|
||||
editable: true,
|
||||
icon: zone.icon,
|
||||
friendly_name: zone.name,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const zoneEntities = (): EntityInput[] => [
|
||||
zoneEntity("zone.home", HOME),
|
||||
...zones.map((zone) => zoneEntity(`zone.${zone.id}`, zone)),
|
||||
];
|
||||
|
||||
// UI zones are in the entity registry, so the zone editor does not also list
|
||||
// them as YAML zones
|
||||
export const zoneRegistryEntries: EntityRegistryEntry[] = zones.map((zone) => ({
|
||||
config_entry_id: null,
|
||||
config_subentry_id: null,
|
||||
device_id: null,
|
||||
area_id: null,
|
||||
disabled_by: null,
|
||||
entity_id: `zone.${zone.id}`,
|
||||
id: `zone.${zone.id}`,
|
||||
name: null,
|
||||
icon: null,
|
||||
labels: [],
|
||||
categories: {},
|
||||
platform: "zone",
|
||||
hidden_by: null,
|
||||
entity_category: null,
|
||||
has_entity_name: false,
|
||||
unique_id: zone.id,
|
||||
options: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
}));
|
||||
|
||||
const personEntity = (person: DemoPerson, now: number): EntityInput => {
|
||||
const [hoursAgo, state] = lastMove(person);
|
||||
return {
|
||||
entity_id: `person.${person.id}`,
|
||||
state,
|
||||
last_changed: new Date(now - hoursAgo * HOUR).toISOString(),
|
||||
attributes: {
|
||||
friendly_name: person.name,
|
||||
entity_picture: person.picture,
|
||||
latitude: person.latitude,
|
||||
longitude: person.longitude,
|
||||
gps_accuracy: 20,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const personHistory = (person: DemoPerson, now: number): EntityHistoryState[] =>
|
||||
person.timeline.map(([hoursAgo, state]) => {
|
||||
const time = (now - hoursAgo * HOUR) / 1000;
|
||||
return { s: state, a: {}, lu: time, lc: time };
|
||||
});
|
||||
|
||||
export const mockMap = (hass: MockHomeAssistant) => {
|
||||
const now = Date.now();
|
||||
hass.addEntities([
|
||||
...zoneEntities(),
|
||||
...PEOPLE.map((person) => personEntity(person, now)),
|
||||
CAR,
|
||||
]);
|
||||
hass.mockWS(
|
||||
"history/history_during_period",
|
||||
({ entity_ids }: { entity_ids?: string[] }) =>
|
||||
Object.fromEntries(
|
||||
PEOPLE.filter(
|
||||
(person) => !entity_ids || entity_ids.includes(`person.${person.id}`)
|
||||
).map((person) => [`person.${person.id}`, personHistory(person, now)])
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { MarketplaceInfo } from "../../../src/data/marketplace/marketplace";
|
||||
import type {
|
||||
MarketplaceRelease,
|
||||
RepositoryBase,
|
||||
RepositoryInfo,
|
||||
} from "../../../src/data/marketplace/repository";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const baseRepository = {
|
||||
can_install: true,
|
||||
config_flow: false,
|
||||
custom: false,
|
||||
domain: null,
|
||||
file_name: "",
|
||||
hide: false,
|
||||
homeassistant: null,
|
||||
installed: false,
|
||||
installed_version: "",
|
||||
local_path: "",
|
||||
new: false,
|
||||
pending_upgrade: false,
|
||||
status: "default" as const,
|
||||
};
|
||||
|
||||
const repositories: RepositoryBase[] = [
|
||||
{
|
||||
...baseRepository,
|
||||
id: "1",
|
||||
category: "integration",
|
||||
full_name: "AlexxIT/WebRTC",
|
||||
name: "WebRTC Camera",
|
||||
description: "Watch camera streams with low latency through WebRTC.",
|
||||
authors: ["@AlexxIT"],
|
||||
domain: "webrtc",
|
||||
config_flow: true,
|
||||
available_version: "v3.6.1",
|
||||
downloads: 98000,
|
||||
stars: 1900,
|
||||
last_updated: "2026-09-12T08:21:00Z",
|
||||
topics: ["camera", "webrtc"],
|
||||
},
|
||||
{
|
||||
...baseRepository,
|
||||
id: "2",
|
||||
category: "integration",
|
||||
full_name: "basnijholt/adaptive-lighting",
|
||||
name: "Adaptive Lighting",
|
||||
description:
|
||||
"Change the brightness and color temperature of your lights over the day.",
|
||||
authors: ["@basnijholt"],
|
||||
domain: "adaptive_lighting",
|
||||
config_flow: true,
|
||||
available_version: "v1.26.0",
|
||||
installed: true,
|
||||
installed_version: "v1.26.0",
|
||||
status: "installed",
|
||||
downloads: 120000,
|
||||
stars: 2300,
|
||||
last_updated: "2026-08-30T17:02:00Z",
|
||||
topics: ["lights", "circadian-rhythm"],
|
||||
},
|
||||
{
|
||||
...baseRepository,
|
||||
id: "3",
|
||||
category: "integration",
|
||||
full_name: "blakeblackshear/frigate-hass-integration",
|
||||
name: "Frigate",
|
||||
description: "Use the cameras and events of a Frigate NVR.",
|
||||
authors: ["@blakeblackshear", "@dermotduffy", "@NickM-27"],
|
||||
domain: "frigate",
|
||||
config_flow: true,
|
||||
available_version: "v5.9.0",
|
||||
installed: true,
|
||||
installed_version: "v5.8.0",
|
||||
pending_upgrade: true,
|
||||
status: "pending-upgrade",
|
||||
downloads: 150000,
|
||||
stars: 900,
|
||||
last_updated: "2026-09-25T11:45:00Z",
|
||||
topics: ["camera", "nvr"],
|
||||
},
|
||||
{
|
||||
...baseRepository,
|
||||
id: "4",
|
||||
category: "plugin",
|
||||
full_name: "piitaya/lovelace-mushroom",
|
||||
name: "Mushroom",
|
||||
description: "A collection of cards to build a clean dashboard.",
|
||||
authors: ["@piitaya"],
|
||||
file_name: "mushroom.js",
|
||||
available_version: "v5.1.0",
|
||||
installed: true,
|
||||
installed_version: "v5.1.0",
|
||||
status: "installed",
|
||||
downloads: 310000,
|
||||
stars: 4400,
|
||||
last_updated: "2026-09-02T09:10:00Z",
|
||||
topics: ["cards", "dashboard"],
|
||||
},
|
||||
{
|
||||
...baseRepository,
|
||||
id: "5",
|
||||
category: "plugin",
|
||||
full_name: "thomasloven/lovelace-card-mod",
|
||||
name: "card-mod",
|
||||
description: "Add CSS styles to any card of your dashboard.",
|
||||
authors: ["@thomasloven"],
|
||||
file_name: "card-mod.js",
|
||||
available_version: "v4.0.0",
|
||||
downloads: 280000,
|
||||
stars: 1500,
|
||||
last_updated: "2026-07-19T14:33:00Z",
|
||||
topics: ["css", "dashboard"],
|
||||
},
|
||||
{
|
||||
...baseRepository,
|
||||
id: "6",
|
||||
category: "plugin",
|
||||
full_name: "custom-cards/button-card",
|
||||
name: "button-card",
|
||||
description: "A button card that you can change in almost every way.",
|
||||
authors: ["@RomRider"],
|
||||
file_name: "button-card.js",
|
||||
available_version: "v5.0.0",
|
||||
new: true,
|
||||
status: "new",
|
||||
downloads: 240000,
|
||||
stars: 2100,
|
||||
last_updated: "2026-09-28T19:05:00Z",
|
||||
topics: ["button", "cards"],
|
||||
},
|
||||
{
|
||||
...baseRepository,
|
||||
id: "7",
|
||||
category: "theme",
|
||||
full_name: "catppuccin/home-assistant",
|
||||
name: "Catppuccin",
|
||||
description: "Soothing pastel themes for Home Assistant.",
|
||||
authors: ["@catppuccin"],
|
||||
file_name: "catppuccin.yaml",
|
||||
available_version: "v2.1.0",
|
||||
downloads: 45000,
|
||||
stars: 300,
|
||||
last_updated: "2026-06-11T07:48:00Z",
|
||||
topics: ["theme"],
|
||||
},
|
||||
];
|
||||
|
||||
const repositoryInfo = (repository: RepositoryBase): RepositoryInfo => ({
|
||||
...repository,
|
||||
additional_info: `# ${repository.name}\n\n${repository.description}\n\nThis is a demo. Installing is not available.`,
|
||||
default_branch: "main",
|
||||
issues: 12,
|
||||
releases: [repository.available_version],
|
||||
ref: repository.available_version,
|
||||
replaces_built_in: false,
|
||||
selected_tag: null,
|
||||
update_entity_id: null,
|
||||
version_or_commit: "version",
|
||||
});
|
||||
|
||||
const info: MarketplaceInfo = {
|
||||
categories: ["integration", "plugin", "theme"],
|
||||
disabled_reason: null,
|
||||
github_connected: false,
|
||||
has_pending_tasks: false,
|
||||
lovelace_mode: "storage",
|
||||
stage: "running",
|
||||
startup: false,
|
||||
version: "2.0.0",
|
||||
warning_accepted: true,
|
||||
};
|
||||
|
||||
// Commands that change the Marketplace are not mocked, so the data never
|
||||
// changes and no signal fires.
|
||||
export const mockMarketplace = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("marketplace/info", () => info);
|
||||
|
||||
hass.mockWS("marketplace/repositories/list", () => repositories);
|
||||
|
||||
hass.mockWS(
|
||||
"marketplace/repository/info",
|
||||
({ repository_id }: { repository_id: string }) => {
|
||||
const repository = repositories.find((repo) => repo.id === repository_id);
|
||||
return repository
|
||||
? repositoryInfo(repository)
|
||||
: Promise.reject({ code: "not_found", message: "Unknown repository" });
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"marketplace/repository/releases",
|
||||
({ repository_id }: { repository_id: string }): MarketplaceRelease[] => {
|
||||
const repository = repositories.find((repo) => repo.id === repository_id);
|
||||
return repository
|
||||
? [
|
||||
{
|
||||
tag: repository.available_version,
|
||||
name: repository.available_version,
|
||||
published_at: String(repository.last_updated),
|
||||
prerelease: false,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("marketplace/subscribe", () => () => undefined);
|
||||
};
|
||||
@@ -60,7 +60,7 @@ const generateSumStatistics = (
|
||||
end: Date,
|
||||
period: "5minute" | "hour" | "day" | "month" = "hour",
|
||||
initValue: number,
|
||||
maxDiff: number
|
||||
maxDiff: number | ((start: Date, end: Date) => number)
|
||||
): StatisticValue[] => {
|
||||
const statistics: StatisticValue[] = [];
|
||||
let currentDate = new Date(start);
|
||||
@@ -69,7 +69,9 @@ const generateSumStatistics = (
|
||||
const now = new Date();
|
||||
while (end > currentDate && currentDate < now) {
|
||||
const nextDate = getNextDate(currentDate, period);
|
||||
const add = Math.random() * maxDiff;
|
||||
const bound =
|
||||
typeof maxDiff === "function" ? maxDiff(currentDate, nextDate) : maxDiff;
|
||||
const add = Math.random() * bound;
|
||||
sum += add;
|
||||
statistics.push({
|
||||
start: currentDate.getTime(),
|
||||
@@ -136,6 +138,15 @@ const statisticsFunctions: Record<
|
||||
period: "5minute" | "hour" | "day" | "month"
|
||||
) => StatisticValue[]
|
||||
> = {
|
||||
"sensor.energy_water": (_id, start, end, period = "hour") =>
|
||||
generateSumStatistics(
|
||||
start,
|
||||
end,
|
||||
period,
|
||||
4000,
|
||||
(bucketStart, bucketEnd) =>
|
||||
(20 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
|
||||
),
|
||||
"sensor.energy_consumption_tarif_1": (
|
||||
_id: string,
|
||||
start: Date,
|
||||
@@ -301,6 +312,11 @@ const statisticsFunctions: Record<
|
||||
return [...morning, ...production, ...evening, ...rest];
|
||||
},
|
||||
};
|
||||
// Prices are per source unit; water uses litres (4 currency units per m³).
|
||||
const derivedCosts: Record<string, { source: string; price: number }> = {
|
||||
"sensor.energy_water_cost": { source: "sensor.energy_water", price: 0.004 },
|
||||
};
|
||||
|
||||
export const mockRecorder = (mockHass: MockHomeAssistant) => {
|
||||
mockHass.mockWS(
|
||||
"recorder/get_statistics_metadata",
|
||||
@@ -317,10 +333,23 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
|
||||
const end = end_time ? new Date(end_time) : new Date();
|
||||
|
||||
const statistics: Record<string, StatisticValue[]> = {};
|
||||
const cache: Record<string, StatisticValue[]> = {};
|
||||
const getStats = (id: string) => {
|
||||
cache[id] ??= statisticsFunctions[id](id, start, end, period);
|
||||
return cache[id];
|
||||
};
|
||||
|
||||
statistic_ids.forEach((id: string) => {
|
||||
if (id in statisticsFunctions) {
|
||||
statistics[id] = statisticsFunctions[id](id, start, end, period);
|
||||
if (id in derivedCosts) {
|
||||
const { source, price } = derivedCosts[id];
|
||||
let cost = 0;
|
||||
statistics[id] = getStats(source).map((statistic) => {
|
||||
const change = statistic.change! * price;
|
||||
cost += change;
|
||||
return { ...statistic, change, state: cost, sum: cost };
|
||||
});
|
||||
} else if (id in statisticsFunctions) {
|
||||
statistics[id] = getStats(id);
|
||||
} else {
|
||||
const entityState = hass.states[id];
|
||||
const state = entityState ? Number(entityState.state) : 1;
|
||||
|
||||
+2
-22
@@ -1,27 +1,7 @@
|
||||
import type { Zone } from "../../../src/data/zone";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const zones: Zone[] = [
|
||||
{
|
||||
id: "home",
|
||||
name: "Home",
|
||||
icon: "mdi:home",
|
||||
latitude: 52.3731339,
|
||||
longitude: 4.8903147,
|
||||
radius: 100,
|
||||
passive: false,
|
||||
},
|
||||
{
|
||||
id: "work",
|
||||
name: "Work",
|
||||
icon: "mdi:briefcase",
|
||||
latitude: 52.3909184,
|
||||
longitude: 4.8530821,
|
||||
radius: 200,
|
||||
passive: false,
|
||||
},
|
||||
];
|
||||
import { zoneEntities, zones } from "./map";
|
||||
|
||||
export const mockZone = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("zone/list", () => zones);
|
||||
hass.addEntities(zoneEntities());
|
||||
};
|
||||
+7
-1
@@ -12,6 +12,7 @@ import { configs as wcConfigs } from "eslint-plugin-wc";
|
||||
import { configs as a11yConfigs } from "eslint-plugin-lit-a11y";
|
||||
import html from "@html-eslint/eslint-plugin";
|
||||
import importX from "eslint-plugin-import-x";
|
||||
import ha from "./build-scripts/eslint-rules/index.mjs";
|
||||
|
||||
const rspackConfigPath = fileURLToPath(
|
||||
new URL("./rspack.config.cjs", import.meta.url)
|
||||
@@ -33,6 +34,7 @@ export default tseslint.config(
|
||||
{
|
||||
plugins: {
|
||||
"unused-imports": unusedImports,
|
||||
ha,
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
@@ -231,6 +233,10 @@ export default tseslint.config(
|
||||
],
|
||||
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
// Off because the existing backlog would fail lint:eslint's
|
||||
// --max-warnings=0; run lint:element-imports to see it. Disable
|
||||
// comments for it would be reported as unused, so none until it is on.
|
||||
"ha/no-unused-element-import": "off",
|
||||
"lit/attribute-names": "error",
|
||||
"lit/attribute-value-entities": "off",
|
||||
"lit/no-template-map": "off",
|
||||
@@ -290,7 +296,7 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [".github/scripts/*.mjs"],
|
||||
files: [".github/scripts/*.mts"],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
|
||||
@@ -40,6 +40,7 @@ export default [
|
||||
"ha-select-box",
|
||||
"ha-input",
|
||||
"ha-textarea",
|
||||
"ha-picker-combo-box",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -52,6 +53,7 @@ export default [
|
||||
"ha-control-switch",
|
||||
"ha-slider",
|
||||
"ha-control-slider",
|
||||
"ha-control-scrubber",
|
||||
"ha-control-circular-slider",
|
||||
"ha-control-number-buttons",
|
||||
"ha-control-select",
|
||||
@@ -101,6 +103,7 @@ export default [
|
||||
"entity-button-card",
|
||||
"entity-filter-card",
|
||||
"glance-card",
|
||||
"sensor-card",
|
||||
"tile-card",
|
||||
"area-card",
|
||||
],
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { html, css, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../src/components/ha-formfield";
|
||||
import "../../../src/components/ha-switch";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import "./demo-card";
|
||||
@@ -18,9 +17,7 @@ class DemoCards extends LitElement {
|
||||
render() {
|
||||
return html`
|
||||
<ha-demo-options>
|
||||
<ha-formfield label="Show config">
|
||||
<ha-switch @change=${this._showConfigToggled}> </ha-switch>
|
||||
</ha-formfield>
|
||||
<ha-switch @change=${this._showConfigToggled}>Show config</ha-switch>
|
||||
</ha-demo-options>
|
||||
<div id="container">
|
||||
<div class="cards">
|
||||
@@ -51,7 +48,7 @@ class DemoCards extends LitElement {
|
||||
demo-card {
|
||||
margin: 16px 16px 32px;
|
||||
}
|
||||
ha-formfield {
|
||||
ha-switch {
|
||||
margin-right: 16px;
|
||||
}
|
||||
#container {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../src/components/ha-formfield";
|
||||
import "../../../src/components/ha-switch";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import "../ha-demo-options";
|
||||
@@ -17,9 +16,7 @@ class DemoMoreInfos extends LitElement {
|
||||
render() {
|
||||
return html`
|
||||
<ha-demo-options>
|
||||
<ha-formfield label="Show config">
|
||||
<ha-switch @change=${this._showConfigToggled}> </ha-switch>
|
||||
</ha-formfield>
|
||||
<ha-switch @change=${this._showConfigToggled}>Show config</ha-switch>
|
||||
</ha-demo-options>
|
||||
<div id="container">
|
||||
<div class="cards">
|
||||
@@ -49,7 +46,7 @@ class DemoMoreInfos extends LitElement {
|
||||
demo-more-info {
|
||||
margin: var(--ha-space-4) var(--ha-space-4) var(--ha-space-8);
|
||||
}
|
||||
ha-formfield {
|
||||
ha-switch {
|
||||
margin-right: var(--ha-space-4);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -151,6 +151,16 @@ export const createMediaPlayerEntities = () => [
|
||||
supported_features: 289,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "media_player.theater_off_assumed",
|
||||
state: "off",
|
||||
attributes: {
|
||||
friendly_name: "TV Off (assumed state, cannot be switched on)",
|
||||
assumed_state: true,
|
||||
// Off + Next + Pause
|
||||
supported_features: 289,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "media_player.theater_on_static",
|
||||
state: "on",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ifDefined } from "lit/directives/if-defined";
|
||||
import { applyThemesOnElement } from "../../src/common/dom/apply_themes_on_element";
|
||||
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
|
||||
import type { HASSDomEvent } from "../../src/common/dom/fire_event";
|
||||
import { listenMediaQuery } from "../../src/common/dom/media_query";
|
||||
import { computeEntityNameDisplayWithoutContext } from "../../src/common/entity/compute_entity_name_display";
|
||||
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
|
||||
import "../../src/components/ha-button";
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
floorsContext,
|
||||
formattersContext,
|
||||
internationalizationContext,
|
||||
reducedMotionContext,
|
||||
registriesContext,
|
||||
servicesContext,
|
||||
statesContext,
|
||||
@@ -161,6 +163,13 @@ class HaGallery extends LitElement {
|
||||
floors: new ContextProvider(this, { context: floorsContext }),
|
||||
};
|
||||
|
||||
private _reducedMotionProvider = new ContextProvider(this, {
|
||||
context: reducedMotionContext,
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
private _unsubReducedMotion?: () => void;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Refresh the fallback contexts before each render so theme/page changes in
|
||||
@@ -267,6 +276,10 @@ class HaGallery extends LitElement {
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
mql.addEventListener("change", this._systemDarkChanged);
|
||||
this._unsubReducedMotion = listenMediaQuery(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
(matches) => this._reducedMotionProvider.setValue(matches)
|
||||
);
|
||||
}
|
||||
|
||||
firstUpdated(changedProps: PropertyValues<this>) {
|
||||
@@ -300,6 +313,8 @@ class HaGallery extends LitElement {
|
||||
super.disconnectedCallback();
|
||||
mql.removeEventListener("change", this._systemDarkChanged);
|
||||
window.removeEventListener("hashchange", this._hashChanged);
|
||||
this._unsubReducedMotion?.();
|
||||
this._unsubReducedMotion = undefined;
|
||||
}
|
||||
|
||||
private _hashChanged = () => {
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
<script type="module" src="<%= entry %>"></script>
|
||||
<% } %>
|
||||
<style>
|
||||
html {
|
||||
/* Prevent iOS Safari from inflating text when rotating to landscape */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
body {
|
||||
font-family: Roboto, Noto, sans-serif;
|
||||
margin: 0;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import "../../../../src/components/ha-formfield";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import "../../components/demo-black-white-row";
|
||||
@@ -64,13 +63,12 @@ class DemoHaAutomationEditorAction extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="options">
|
||||
<ha-formfield label="Disabled">
|
||||
<ha-switch
|
||||
.name=${"disabled"}
|
||||
.checked=${this._disabled}
|
||||
@change=${this._handleOptionChange}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
<ha-switch
|
||||
.name=${"disabled"}
|
||||
.checked=${this._disabled}
|
||||
@change=${this._handleOptionChange}
|
||||
>Disabled</ha-switch
|
||||
>
|
||||
</div>
|
||||
${SCHEMAS.map(
|
||||
(info, sampleIdx) => html`
|
||||
@@ -111,7 +109,7 @@ class DemoHaAutomationEditorAction extends LitElement {
|
||||
max-width: 800px;
|
||||
margin: 16px auto;
|
||||
}
|
||||
.options ha-formfield {
|
||||
.options ha-switch {
|
||||
margin-right: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
|
||||
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
|
||||
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
|
||||
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
|
||||
import "../../../../src/components/ha-formfield";
|
||||
import type { ConditionWithShorthand } from "../../../../src/data/automation";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../../../src/panels/config/automation/condition/ha-automation-condition";
|
||||
@@ -106,13 +105,12 @@ export class DemoAutomationEditorCondition extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="options">
|
||||
<ha-formfield label="Disabled">
|
||||
<ha-switch
|
||||
.name=${"disabled"}
|
||||
.checked=${this._disabled}
|
||||
@change=${this._handleOptionChange}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
<ha-switch
|
||||
.name=${"disabled"}
|
||||
.checked=${this._disabled}
|
||||
@change=${this._handleOptionChange}
|
||||
>Disabled</ha-switch
|
||||
>
|
||||
</div>
|
||||
${SCHEMAS.map(
|
||||
(info, sampleIdx) => html`
|
||||
@@ -153,7 +151,7 @@ export class DemoAutomationEditorCondition extends LitElement {
|
||||
max-width: 800px;
|
||||
margin: 16px auto;
|
||||
}
|
||||
.options ha-formfield {
|
||||
.options ha-switch {
|
||||
margin-right: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -8,7 +8,6 @@ import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
|
||||
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
|
||||
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
|
||||
import { mockTags } from "../../../../demo/src/stubs/tags";
|
||||
import "../../../../src/components/ha-formfield";
|
||||
import type { Trigger } from "../../../../src/data/automation";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../../../src/panels/config/automation/trigger/ha-automation-trigger";
|
||||
@@ -146,13 +145,12 @@ export class DemoAutomationEditorTrigger extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="options">
|
||||
<ha-formfield label="Disabled">
|
||||
<ha-switch
|
||||
.name=${"disabled"}
|
||||
.checked=${this._disabled}
|
||||
@change=${this._handleOptionChange}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
<ha-switch
|
||||
.name=${"disabled"}
|
||||
.checked=${this._disabled}
|
||||
@change=${this._handleOptionChange}
|
||||
>Disabled</ha-switch
|
||||
>
|
||||
</div>
|
||||
${SCHEMAS.map(
|
||||
(info, sampleIdx) => html`
|
||||
@@ -193,7 +191,7 @@ export class DemoAutomationEditorTrigger extends LitElement {
|
||||
max-width: 800px;
|
||||
margin: 16px auto;
|
||||
}
|
||||
.options ha-formfield {
|
||||
.options ha-switch {
|
||||
margin-right: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -9,7 +9,6 @@ import "../../../../src/components/ha-form/ha-form";
|
||||
import "../../../../src/components/ha-icon-button";
|
||||
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
const SCHEMA: HaFormSchema[] = [
|
||||
{ type: "string", name: "Name", default: "", autofocus: true },
|
||||
@@ -31,11 +30,8 @@ type DialogType =
|
||||
export class DemoHaAdaptiveDialog extends LitElement {
|
||||
@state() private _openDialog: DialogType = false;
|
||||
|
||||
@state() private _hass?: HomeAssistant;
|
||||
|
||||
protected firstUpdated() {
|
||||
const hass = provideHass(this);
|
||||
this._hass = hass;
|
||||
provideHass(this);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -89,7 +85,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-card>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "basic"}
|
||||
header-title="Basic adaptive dialog"
|
||||
@closed=${this._handleClosed}
|
||||
@@ -98,7 +93,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "basic-subtitle-below"}
|
||||
header-title="Adaptive dialog with subtitle"
|
||||
header-subtitle="This is an adaptive dialog with a subtitle below"
|
||||
@@ -108,7 +102,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "basic-subtitle-above"}
|
||||
header-title="Adaptive dialog with subtitle above"
|
||||
header-subtitle="This is an adaptive dialog with a subtitle above"
|
||||
@@ -119,7 +112,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "small"}
|
||||
width="small"
|
||||
header-title="Small adaptive dialog"
|
||||
@@ -129,7 +121,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "large"}
|
||||
width="large"
|
||||
header-title="Large adaptive dialog"
|
||||
@@ -139,7 +130,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "form"}
|
||||
header-title="Adaptive dialog with form"
|
||||
header-subtitle="This is an adaptive dialog with a form"
|
||||
@@ -163,7 +153,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.allowModeChange=${this._openDialog === "allow-mode-change"}
|
||||
header-title="Adaptive dialog with allow mode change"
|
||||
header-subtitle="Resize the window while this dialog is open"
|
||||
@@ -176,7 +165,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "actions"}
|
||||
header-title="Adaptive dialog with actions"
|
||||
header-subtitle="This is an adaptive dialog with header actions"
|
||||
@@ -395,7 +383,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
<h3>Example usage</h3>
|
||||
|
||||
<pre><code><ha-adaptive-dialog
|
||||
.hass=\${this.hass}
|
||||
open
|
||||
header-title="Dialog title"
|
||||
header-subtitle="Dialog subtitle"
|
||||
@@ -607,7 +594,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
|
||||
<p>Example:</p>
|
||||
|
||||
<pre><code><ha-adaptive-dialog .hass=\${this.hass} open>
|
||||
<pre><code><ha-adaptive-dialog open>
|
||||
<ha-form autofocus .schema=\${schema}></ha-form>
|
||||
</ha-adaptive-dialog></code></pre>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,6 @@ import "../../../../src/components/ha-form/ha-form";
|
||||
import "../../../../src/components/ha-icon-button";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
|
||||
|
||||
const SCHEMA: HaFormSchema[] = [
|
||||
@@ -23,12 +22,10 @@ type PopoverType = false | "basic" | "small" | "large" | "form" | "actions";
|
||||
export class DemoHaAdaptivePopover extends LitElement {
|
||||
@state() private _openPopover: PopoverType = false;
|
||||
|
||||
@state() private _hass?: HomeAssistant;
|
||||
|
||||
@state() private _dialogAnchor?: HTMLElement;
|
||||
|
||||
protected firstUpdated() {
|
||||
this._hass = provideHass(this);
|
||||
provideHass(this);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -72,7 +69,6 @@ export class DemoHaAdaptivePopover extends LitElement {
|
||||
</ha-card>
|
||||
|
||||
<ha-adaptive-popover
|
||||
.hass=${this._hass}
|
||||
.open=${this._openPopover === "basic"}
|
||||
.dialogAnchor=${this._dialogAnchor}
|
||||
header-title="Basic adaptive popover"
|
||||
@@ -86,7 +82,6 @@ export class DemoHaAdaptivePopover extends LitElement {
|
||||
</ha-adaptive-popover>
|
||||
|
||||
<ha-adaptive-popover
|
||||
.hass=${this._hass}
|
||||
.open=${this._openPopover === "small"}
|
||||
.dialogAnchor=${this._dialogAnchor}
|
||||
width="small"
|
||||
@@ -97,7 +92,6 @@ export class DemoHaAdaptivePopover extends LitElement {
|
||||
</ha-adaptive-popover>
|
||||
|
||||
<ha-adaptive-popover
|
||||
.hass=${this._hass}
|
||||
.open=${this._openPopover === "large"}
|
||||
.dialogAnchor=${this._dialogAnchor}
|
||||
width="large"
|
||||
@@ -108,7 +102,6 @@ export class DemoHaAdaptivePopover extends LitElement {
|
||||
</ha-adaptive-popover>
|
||||
|
||||
<ha-adaptive-popover
|
||||
.hass=${this._hass}
|
||||
.open=${this._openPopover === "form"}
|
||||
.dialogAnchor=${this._dialogAnchor}
|
||||
header-title="Adaptive popover with form"
|
||||
@@ -133,7 +126,6 @@ export class DemoHaAdaptivePopover extends LitElement {
|
||||
</ha-adaptive-popover>
|
||||
|
||||
<ha-adaptive-popover
|
||||
.hass=${this._hass}
|
||||
.open=${this._openPopover === "actions"}
|
||||
.dialogAnchor=${this._dialogAnchor}
|
||||
header-title="Adaptive popover with actions"
|
||||
|
||||
@@ -51,11 +51,12 @@ The badge component is a small component that displays a number or status inform
|
||||
|
||||
**Properties/Attributes**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| -------- | ----------------------- | ----------- | ------------------------------------------------------------ |
|
||||
| type | `"badge"` or `"button"` | `"badge"` | If it's button it shows a ripple effect |
|
||||
| label | string | `undefined` | Text label for the badge, only visible if `iconOnly = false` |
|
||||
| iconOnly | boolean | `false` | Only show label |
|
||||
| Name | Type | Default | Description |
|
||||
| --------------- | ----------------------- | ----------- | ------------------------------------------------------------- |
|
||||
| type | `"badge"` or `"button"` | `"badge"` | If it's button it shows a ripple effect |
|
||||
| label | string | `undefined` | Text label for the badge, only visible if `iconOnly = false` |
|
||||
| iconOnly | boolean | `false` | Only show label |
|
||||
| accessibleLabel | string | `undefined` | Accessible name of the button, only used if `iconOnly = true` |
|
||||
|
||||
**CSS Custom Properties**
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Control scrubber
|
||||
---
|
||||
|
||||
A horizontal control where the value sits under a fixed window in the middle and the background strip moves. Drag the strip to change the value, or tap a point of the strip to bring it under the window.
|
||||
|
||||
Set `--control-scrubber-track-width` to make the strip wider than the control, so only a part of the range is visible at a time and each pixel of drag changes the value less. Set `wrap` for cyclic ranges such as hue, where the strip repeats and the value wraps around at the bounds. Set `show-handle` to draw the window, and leave it off when there is no current value to point at, such as a light that is off.
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-control-scrubber";
|
||||
|
||||
@customElement("demo-components-ha-control-scrubber")
|
||||
export class DemoHaControlScrubber extends LitElement {
|
||||
@state() private hue = 200;
|
||||
|
||||
@state() private position?: number;
|
||||
|
||||
handleHueChanged(e: CustomEvent) {
|
||||
this.hue = e.detail.value as number;
|
||||
}
|
||||
|
||||
handleMoved(e: CustomEvent) {
|
||||
this.position = e.detail.value as number;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<p><b>Scrubber values</b></p>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>position</td>
|
||||
<td>${this.position ?? "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>value</td>
|
||||
<td>${this.hue}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ha-card>
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<label id="scrubber-hue">Hue (wrap)</label>
|
||||
<pre>Config: {"wrap":true,"min":0,"max":360}</pre>
|
||||
<ha-control-scrubber
|
||||
wrap
|
||||
show-handle
|
||||
min="0"
|
||||
max="360"
|
||||
unit="°"
|
||||
.value=${this.hue}
|
||||
@value-changed=${this.handleHueChanged}
|
||||
@slider-moved=${this.handleMoved}
|
||||
label="Hue"
|
||||
>
|
||||
</ha-control-scrubber>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
max-width: 600px;
|
||||
margin: 24px auto;
|
||||
}
|
||||
pre {
|
||||
margin-top: 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
label {
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
}
|
||||
ha-control-scrubber {
|
||||
--control-scrubber-track-width: 200%;
|
||||
--control-scrubber-background: linear-gradient(
|
||||
to right,
|
||||
hsl(0 100% 50%),
|
||||
hsl(60 100% 50%),
|
||||
hsl(120 100% 50%),
|
||||
hsl(180 100% 50%),
|
||||
hsl(240 100% 50%),
|
||||
hsl(300 100% 50%),
|
||||
hsl(360 100% 50%)
|
||||
);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-ha-control-scrubber": DemoHaControlScrubber;
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,18 @@ A styled container with roving-tabindex keyboard navigation. Host role is
|
||||
Home/End jump to the first/last enabled item; Enter/Space activates the
|
||||
focused item.
|
||||
|
||||
With `virtual-focus`, DOM focus stays outside the list, for example in the
|
||||
search field of a picker. The list then ignores key presses. The element that
|
||||
owns the focus calls `moveActiveItem()`, and the list marks the row it points at
|
||||
with the `active` attribute and scrolls it into view instead of focusing it.
|
||||
|
||||
**Attributes**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| ------------ | ------- | ------- | ------------------------------ |
|
||||
| `wrap-focus` | Boolean | `false` | Arrow keys wrap past the ends. |
|
||||
| `aria-label` | String | — | Accessible name. |
|
||||
| Name | Type | Default | Description |
|
||||
| --------------- | ------- | ------- | ---------------------------------------------------------------- |
|
||||
| `wrap-focus` | Boolean | `false` | Arrow keys wrap past the ends. |
|
||||
| `virtual-focus` | Boolean | `false` | Keep DOM focus outside the list and mark the active row instead. |
|
||||
| `aria-label` | String | — | Accessible name. |
|
||||
|
||||
**Events**
|
||||
|
||||
@@ -36,6 +42,10 @@ focused item.
|
||||
- `getActiveItemIndex()` — current active index, or `-1`.
|
||||
- `setActiveItemIndex(index, focusItem?)` — move the active index without
|
||||
necessarily focusing.
|
||||
- `moveActiveItem(target)` — move the active row to `"next"`, `"previous"`,
|
||||
`"first"`, `"last"`, `"next-page"` or `"previous-page"`, skipping rows that
|
||||
are not interactive or disabled. Meant for `virtual-focus`.
|
||||
- `clearActiveItem()` — clear the active row.
|
||||
- `updateListItems()` — re-discover slotted items (called automatically on
|
||||
slotchange).
|
||||
|
||||
@@ -134,6 +144,8 @@ Non-interactive list row. Host role is `listitem`. Inherits everything from
|
||||
- `interactive` (Boolean, default `false`) — opt this row into the parent
|
||||
list's roving tabindex. Useful for sortable rows that need keyboard focus
|
||||
but no click action. Interactive subclasses set this automatically.
|
||||
- `active` (Boolean) — set by a parent list with `virtual-focus` on the row
|
||||
that keyboard navigation points at.
|
||||
|
||||
**CSS custom properties**
|
||||
|
||||
@@ -143,6 +155,8 @@ Non-interactive list row. Host role is `listitem`. Inherits everything from
|
||||
the focus-in animation.
|
||||
- `--ha-list-item-focus-offset` — focus outline offset.
|
||||
- `--ha-list-item-focus-background` — background color on keyboard focus.
|
||||
- `--ha-list-item-active-background` — background color of the `active` row.
|
||||
Defaults to `--ha-list-item-focus-background`.
|
||||
|
||||
### `<ha-list-item-button>`
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
title: Picker combo box
|
||||
---
|
||||
|
||||
# Picker combo box `<ha-picker-combo-box>`
|
||||
|
||||
The searchable list used inside pickers. `ha-generic-picker` renders it in a
|
||||
popover or dialog; entity, area, floor, label and device pickers all reach it
|
||||
that way. It is shown directly here so the list and its keyboard model can be
|
||||
inspected on their own.
|
||||
|
||||
It takes no `hass`. Items come from a `getItems` callback and locale comes from
|
||||
`internationalizationContext`.
|
||||
|
||||
## Two render paths
|
||||
|
||||
The item count picks the renderer, and the two behave differently enough that
|
||||
list changes need checking against both.
|
||||
|
||||
| Items | Renderer | Rows in the DOM |
|
||||
| ------------------------------------------- | ----------------- | --------------------- |
|
||||
| 12 or fewer rendered rows, no sections | `repeat()` | all of them |
|
||||
| more than 12 rendered rows, or any sections | `lit-virtualizer` | only the visible ones |
|
||||
|
||||
The threshold is `MAX_PLAIN_LIST_ITEMS`, counted against the initial rendered
|
||||
row set, including additional items and dialog padding. Filtering does not switch renderers.
|
||||
|
||||
## Two highlights
|
||||
|
||||
The list has two row backgrounds.
|
||||
|
||||
- **`selected`** — the row matching the `value` property, rendered onto its
|
||||
`ha-list-item-option`.
|
||||
- **`active`** — the keyboard cursor, and the row <kbd>Enter</kbd> picks. The
|
||||
list runs in `virtual-focus` mode: DOM focus stays in the search field, and
|
||||
the list owns the active row, skips disabled rows and section titles, and
|
||||
scrolls it into view.
|
||||
|
||||
## Keyboard
|
||||
|
||||
| Key | Behavior |
|
||||
| ------------------------------------- | ----------------------------------------------------- |
|
||||
| <kbd>↑</kbd> <kbd>↓</kbd> | Move the cursor |
|
||||
| <kbd>Home</kbd> <kbd>End</kbd> | Jump to the first or last item |
|
||||
| <kbd>PageUp</kbd> <kbd>PageDown</kbd> | Move the cursor a page |
|
||||
| <kbd>Enter</kbd> | Pick the row under the cursor |
|
||||
| <kbd>Ctrl/⌘</kbd> + <kbd>Enter</kbd> | Pick it and report `newTab: true` in `index-selected` |
|
||||
|
||||
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.
|
||||
|
||||
## Implementation
|
||||
|
||||
Without `sections`, the component filters the items from `getItems` itself as the
|
||||
user types, so `getItems` can ignore its search argument. With sections,
|
||||
`getItems` receives the search string and selected section and must return the
|
||||
matching rows.
|
||||
|
||||
The host is `display: flex` with `flex: 1` and expects a parent with a bounded
|
||||
height. In the app that bound comes from the popover; embedding it directly
|
||||
means supplying one.
|
||||
|
||||
### Properties/Attributes
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| ------------------ | ------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| getItems | Function | - | Required. Returns the items, optionally filtered by search string and section. |
|
||||
| getAdditionalItems | Function | - | Extra items appended to search results, for example "add new". |
|
||||
| value | String | - | Id of the current item. Marks its row `selected`, and the virtualized list opens with that row in view. |
|
||||
| label | String | - | Placeholder for the search field. Falls back to a localized "Search". |
|
||||
| mode | "popover"/"dialog" | "popover" | Adjusts padding for the surface the list sits in. |
|
||||
| shown | Boolean | true | Whether the surface finished animating. `ha-generic-picker` sets it so the virtualizer does not measure rows mid-animation. |
|
||||
| sections | Array | - | Filter chips. Section headers are plain strings returned by `getItems`, and the sticky title shows the one above the first visible row. |
|
||||
| selectedSection | String | - | Section filter selected when the list is initialized. |
|
||||
| searchKeys | Array | - | Fuse weighted keys for fuzzy search. |
|
||||
| searchFn | Function | - | Post-processes filtered results. |
|
||||
| rowRenderer | Function | - | Replaces the default row template. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
### Events
|
||||
|
||||
| Event | Detail | Description |
|
||||
| ---------------- | ------------------------- | -------------------------------------------------------------- |
|
||||
| `value-changed` | `{ value }` | An item was picked. |
|
||||
| `index-selected` | `{ index, item, newTab }` | Which row was picked, and whether it should open in a new tab. |
|
||||
@@ -0,0 +1,273 @@
|
||||
import { ContextProvider } from "@lit/context";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-picker-combo-box";
|
||||
import type { PickerComboBoxItem } from "../../../../src/components/ha-picker-combo-box";
|
||||
import { internationalizationContext } from "../../../../src/data/context";
|
||||
import {
|
||||
DateFormat,
|
||||
FirstWeekday,
|
||||
NumberFormat,
|
||||
TimeFormat,
|
||||
TimeZone,
|
||||
} from "../../../../src/data/translation";
|
||||
import type {
|
||||
HomeAssistantInternationalization,
|
||||
ValueChangedEvent,
|
||||
} from "../../../../src/types";
|
||||
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
const localize = (key: string) => LOCALIZE_KEYS[key] ?? key;
|
||||
|
||||
const DEMO_I18N: HomeAssistantInternationalization = {
|
||||
localize,
|
||||
language: "en",
|
||||
selectedLanguage: null,
|
||||
locale: {
|
||||
language: "en",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.language,
|
||||
date_format: DateFormat.language,
|
||||
first_weekday: FirstWeekday.language,
|
||||
time_zone: TimeZone.local,
|
||||
},
|
||||
translationMetadata: { fragments: [], translations: {} },
|
||||
loadBackendTranslation: async () => localize,
|
||||
loadFragmentTranslation: async () => localize,
|
||||
};
|
||||
|
||||
const PLAIN_ITEMS: PickerComboBoxItem[] = [
|
||||
{ id: "light.desk", primary: "Desk", secondary: "Office" },
|
||||
{ id: "light.dining_table", primary: "Dining table", secondary: "Kitchen" },
|
||||
{ id: "light.doorway", primary: "Doorway", secondary: "Hallway" },
|
||||
{ id: "light.hallway", primary: "Hallway", secondary: "Hallway" },
|
||||
{
|
||||
id: "light.kitchen_counter",
|
||||
primary: "Kitchen counter",
|
||||
secondary: "Kitchen",
|
||||
},
|
||||
{ id: "light.porch", primary: "Porch", secondary: "Outside" },
|
||||
{ id: "light.reading_lamp", primary: "Reading lamp", secondary: "Bedroom" },
|
||||
{
|
||||
id: "light.shed",
|
||||
primary: "Shed (disabled)",
|
||||
secondary: "Outside",
|
||||
disabled: true,
|
||||
},
|
||||
{ id: "light.workbench", primary: "Workbench", secondary: "Garage" },
|
||||
];
|
||||
|
||||
const AREAS = [
|
||||
"Attic",
|
||||
"Basement",
|
||||
"Bedroom",
|
||||
"Dining room",
|
||||
"Garage",
|
||||
"Hallway",
|
||||
"Kitchen",
|
||||
"Living room",
|
||||
"Office",
|
||||
"Outside",
|
||||
];
|
||||
|
||||
const FIXTURES = [
|
||||
"Ceiling",
|
||||
"Corner lamp",
|
||||
"Cupboard",
|
||||
"Desk lamp",
|
||||
"Downlight",
|
||||
"Floor lamp",
|
||||
"Shelf",
|
||||
"Spotlight",
|
||||
"Strip",
|
||||
"Wall sconce",
|
||||
];
|
||||
|
||||
const VIRTUALIZED_ITEMS: PickerComboBoxItem[] = AREAS.flatMap((area, a) =>
|
||||
FIXTURES.flatMap((fixture) =>
|
||||
[1, 2].map((offset) => {
|
||||
const n = a * 2 + offset;
|
||||
return {
|
||||
id: `light.${fixture.toLowerCase().replace(/ /g, "_")}_${n}`,
|
||||
primary: `${fixture} ${n}`,
|
||||
secondary: area,
|
||||
};
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@customElement("demo-components-ha-picker-combo-box")
|
||||
export class DemoHaPickerComboBox extends LitElement {
|
||||
@state() private _plainValue?: string;
|
||||
|
||||
@state() private _virtualizedValue?: string;
|
||||
|
||||
@state() private _preselectedValue = "light.spotlight_13";
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Provides internationalizationContext for ha-picker-combo-box and ha-input-search
|
||||
new ContextProvider(this, {
|
||||
context: internationalizationContext,
|
||||
initialValue: DEMO_I18N,
|
||||
});
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<demo-theme-comparison>
|
||||
${THEME_COMPARISON_PANELS.map(
|
||||
({ slot }) => html`
|
||||
<div slot=${slot} class="panel-content">
|
||||
<ha-card header="Plain list (9 items)">
|
||||
<div class="card-content">
|
||||
<p class="note">
|
||||
At or below 12 items the list renders with
|
||||
<code>repeat()</code>, so every row is in the DOM. One item
|
||||
is disabled, so neither a click nor <kbd>Enter</kbd>
|
||||
picks it.
|
||||
</p>
|
||||
<div class="picker">
|
||||
<ha-picker-combo-box
|
||||
.getItems=${this._getPlainItems}
|
||||
.value=${this._plainValue}
|
||||
@value-changed=${this._plainValueChanged}
|
||||
></ha-picker-combo-box>
|
||||
</div>
|
||||
<p class="value">
|
||||
Picked: <code>${this._plainValue ?? "—"}</code>
|
||||
</p>
|
||||
</div>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="Virtualized list (200 items)">
|
||||
<div class="card-content">
|
||||
<p class="note">
|
||||
Above 12 items the list switches to
|
||||
<code>lit-virtualizer</code> and only the visible rows
|
||||
exist.
|
||||
</p>
|
||||
<div class="picker">
|
||||
<ha-picker-combo-box
|
||||
.getItems=${this._getVirtualizedItems}
|
||||
.value=${this._virtualizedValue}
|
||||
@value-changed=${this._virtualizedValueChanged}
|
||||
></ha-picker-combo-box>
|
||||
</div>
|
||||
<p class="value">
|
||||
Picked: <code>${this._virtualizedValue ?? "—"}</code>
|
||||
</p>
|
||||
</div>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="Empty">
|
||||
<div class="card-content">
|
||||
<p class="note">
|
||||
With no items the list shows
|
||||
<code>emptyLabel</code>, or a localized fallback. Searching
|
||||
a non-empty list that matches nothing shows
|
||||
<code>notFoundLabel</code> instead.
|
||||
</p>
|
||||
<div class="picker">
|
||||
<ha-picker-combo-box
|
||||
.getItems=${this._getNoItems}
|
||||
></ha-picker-combo-box>
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="With a current value">
|
||||
<div class="card-content">
|
||||
<p class="note">
|
||||
A set <code>value</code> marks its row
|
||||
<code>selected</code>. The keyboard cursor is a separate
|
||||
highlight, and <kbd>↓</kbd> starts it on this row.
|
||||
</p>
|
||||
<div class="picker">
|
||||
<ha-picker-combo-box
|
||||
.getItems=${this._getVirtualizedItems}
|
||||
.value=${this._preselectedValue}
|
||||
@value-changed=${this._preselectedValueChanged}
|
||||
></ha-picker-combo-box>
|
||||
</div>
|
||||
<p class="value">
|
||||
Current: <code>${this._preselectedValue}</code>
|
||||
</p>
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</demo-theme-comparison>
|
||||
`;
|
||||
}
|
||||
|
||||
private _getPlainItems = () => PLAIN_ITEMS;
|
||||
|
||||
private _getVirtualizedItems = () => VIRTUALIZED_ITEMS;
|
||||
|
||||
private _getNoItems = (): PickerComboBoxItem[] => [];
|
||||
|
||||
private _plainValueChanged(ev: ValueChangedEvent<string>) {
|
||||
this._plainValue = ev.detail.value;
|
||||
}
|
||||
|
||||
private _virtualizedValueChanged(ev: ValueChangedEvent<string>) {
|
||||
this._virtualizedValue = ev.detail.value;
|
||||
}
|
||||
|
||||
private _preselectedValueChanged(ev: ValueChangedEvent<string>) {
|
||||
this._preselectedValue = ev.detail.value;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.panel-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
ha-card {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.note {
|
||||
margin: 0;
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 320px;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.value {
|
||||
margin: 0;
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-ha-picker-combo-box": DemoHaPickerComboBox;
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,13 @@ import {
|
||||
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
|
||||
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import "../../../../src/components/ha-formfield";
|
||||
import "../../../../src/components/ha-selector/ha-selector";
|
||||
import "../../../../src/components/ha-settings-row";
|
||||
import type { BlueprintInput } from "../../../../src/data/blueprint";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
|
||||
import { StatisticMeanType } from "../../../../src/data/recorder";
|
||||
import { resolveSelectorContext } from "../../../../src/data/selector";
|
||||
import type { SerialPort } from "../../../../src/data/usb";
|
||||
import {
|
||||
showDialog,
|
||||
@@ -384,6 +384,14 @@ const SCHEMAS: {
|
||||
selector: { config_entry: {} },
|
||||
},
|
||||
duration: { name: "Duration", selector: { duration: {} } },
|
||||
signed_duration: {
|
||||
name: "Signed duration",
|
||||
selector: { duration: { mode: "signed" } },
|
||||
},
|
||||
offset_duration: {
|
||||
name: "Offset",
|
||||
selector: { duration: { mode: "offset", enable_day: true } },
|
||||
},
|
||||
app: { name: "App", selector: { app: {} } },
|
||||
serial_port: {
|
||||
name: "Serial port",
|
||||
@@ -671,6 +679,77 @@ const SCHEMAS: {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Unit of measurement",
|
||||
input: {
|
||||
sensor_device_class: {
|
||||
name: "Device class",
|
||||
selector: { device_class: { domain: "sensor" } },
|
||||
},
|
||||
sensor_state_class: {
|
||||
name: "State class",
|
||||
selector: { state_class: {} },
|
||||
},
|
||||
unit_linked: {
|
||||
name: "Unit of the device class and state class above",
|
||||
selector: {
|
||||
unit_of_measurement: {
|
||||
context: {
|
||||
filter_device_class: "sensor_device_class",
|
||||
filter_state_class: "sensor_state_class",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
unit: {
|
||||
name: "Any unit (custom allowed)",
|
||||
selector: { unit_of_measurement: {} },
|
||||
},
|
||||
unit_temperature: {
|
||||
name: "Temperature",
|
||||
selector: { unit_of_measurement: { device_classes: ["temperature"] } },
|
||||
},
|
||||
unit_battery_humidity: {
|
||||
name: "Battery or humidity",
|
||||
selector: {
|
||||
unit_of_measurement: { device_classes: ["battery", "humidity"] },
|
||||
},
|
||||
},
|
||||
unit_aqi: {
|
||||
name: "Air quality index (no unit)",
|
||||
selector: { unit_of_measurement: { device_classes: ["aqi"] } },
|
||||
},
|
||||
unit_enum: {
|
||||
name: "Enum (no unit)",
|
||||
selector: { unit_of_measurement: { device_classes: ["enum"] } },
|
||||
},
|
||||
unit_timestamp: {
|
||||
name: "Timestamp (no unit)",
|
||||
selector: { unit_of_measurement: { device_classes: ["timestamp"] } },
|
||||
},
|
||||
unit_uptime: {
|
||||
name: "Uptime (no unit)",
|
||||
selector: { unit_of_measurement: { device_classes: ["uptime"] } },
|
||||
},
|
||||
unit_monetary: {
|
||||
name: "Monetary (custom unit)",
|
||||
selector: { unit_of_measurement: { device_classes: ["monetary"] } },
|
||||
},
|
||||
unit_angle: {
|
||||
name: "Measurement angle",
|
||||
selector: {
|
||||
unit_of_measurement: { state_classes: ["measurement_angle"] },
|
||||
},
|
||||
},
|
||||
unit_context: {
|
||||
name: "Temperature or humidity, narrowed by context to humidity",
|
||||
selector: {
|
||||
unit_of_measurement: { device_classes: ["temperature", "humidity"] },
|
||||
},
|
||||
context: { filter_device_class: "humidity" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Multiples",
|
||||
input: {
|
||||
@@ -983,34 +1062,30 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="options">
|
||||
<ha-formfield label="Labels">
|
||||
<ha-switch
|
||||
.name=${"label"}
|
||||
.checked=${this._label}
|
||||
@change=${this._handleOptionChange}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
<ha-formfield label="Required">
|
||||
<ha-switch
|
||||
.name=${"required"}
|
||||
.checked=${this._required}
|
||||
@change=${this._handleOptionChange}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
<ha-formfield label="Disabled">
|
||||
<ha-switch
|
||||
.name=${"disabled"}
|
||||
.checked=${this._disabled}
|
||||
@change=${this._handleOptionChange}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
<ha-formfield label="Helper text">
|
||||
<ha-switch
|
||||
.name=${"helper"}
|
||||
.checked=${this._helper}
|
||||
@change=${this._handleOptionChange}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
<ha-switch
|
||||
.name=${"label"}
|
||||
.checked=${this._label}
|
||||
@change=${this._handleOptionChange}
|
||||
>Labels</ha-switch
|
||||
>
|
||||
<ha-switch
|
||||
.name=${"required"}
|
||||
.checked=${this._required}
|
||||
@change=${this._handleOptionChange}
|
||||
>Required</ha-switch
|
||||
>
|
||||
<ha-switch
|
||||
.name=${"disabled"}
|
||||
.checked=${this._disabled}
|
||||
@change=${this._handleOptionChange}
|
||||
>Disabled</ha-switch
|
||||
>
|
||||
<ha-switch
|
||||
.name=${"helper"}
|
||||
.checked=${this._helper}
|
||||
@change=${this._handleOptionChange}
|
||||
>Helper text</ha-switch
|
||||
>
|
||||
</div>
|
||||
${SCHEMAS.map((info, idx) => {
|
||||
const data = this.data[idx];
|
||||
@@ -1031,7 +1106,7 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${value!.selector}
|
||||
.context=${value!.context}
|
||||
.context=${this._context(value!, data)}
|
||||
.key=${key}
|
||||
.label=${this._label ? value!.name : undefined}
|
||||
.value=${data[key] ?? value!.default}
|
||||
@@ -1051,6 +1126,19 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _context(
|
||||
input: (typeof SCHEMAS)[number]["input"][string],
|
||||
data: Record<string, unknown>
|
||||
): Record<string, unknown> | undefined {
|
||||
const selectorContext = input?.selector
|
||||
? resolveSelectorContext(input.selector, data)
|
||||
: undefined;
|
||||
if (!selectorContext) {
|
||||
return input?.context;
|
||||
}
|
||||
return { ...input?.context, ...selectorContext };
|
||||
}
|
||||
|
||||
private _handleValueChanged(ev) {
|
||||
const idx = ev.target.sampleIdx;
|
||||
this.data[idx] = {
|
||||
@@ -1069,7 +1157,7 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
max-width: 800px;
|
||||
margin: 16px auto;
|
||||
}
|
||||
.options ha-formfield {
|
||||
.options ha-switch {
|
||||
margin-right: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -102,6 +102,13 @@ const CONFIGS = [
|
||||
entity: "media_player.theater_off_static",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Player Off (assumed state, cannot be switched on)",
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.theater_off_assumed",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Player On (cannot be switched off)",
|
||||
config: {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
title: Sensor card
|
||||
---
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import { mockHistory } from "../../../../demo/src/stubs/history";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { SensorCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import "../../components/demo-cards";
|
||||
|
||||
const ENTITIES = [
|
||||
{
|
||||
entity_id: "sensor.outside_temperature",
|
||||
state: "15.6",
|
||||
attributes: {
|
||||
friendly_name: "Outside temperature",
|
||||
device_class: "temperature",
|
||||
unit_of_measurement: "°C",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.outside_humidity",
|
||||
state: "54",
|
||||
attributes: {
|
||||
friendly_name: "Outside humidity",
|
||||
device_class: "humidity",
|
||||
unit_of_measurement: "%",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.phone_battery",
|
||||
state: "18",
|
||||
attributes: {
|
||||
friendly_name: "Phone battery",
|
||||
device_class: "battery",
|
||||
unit_of_measurement: "%",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.not_working",
|
||||
state: "unavailable",
|
||||
attributes: {
|
||||
friendly_name: "Not working",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Basic example",
|
||||
config: {
|
||||
type: "sensor",
|
||||
entity: "sensor.outside_temperature",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With line graph",
|
||||
config: {
|
||||
type: "sensor",
|
||||
entity: "sensor.outside_temperature",
|
||||
graph: "line",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With graph detail and hours to show",
|
||||
config: {
|
||||
type: "sensor",
|
||||
entity: "sensor.outside_humidity",
|
||||
graph: "line",
|
||||
detail: 2,
|
||||
hours_to_show: 6,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With state color",
|
||||
config: {
|
||||
type: "sensor",
|
||||
entity: "sensor.phone_battery",
|
||||
icon: "mdi:battery-20",
|
||||
graph: "line",
|
||||
state_color: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With name, icon and unit",
|
||||
config: {
|
||||
type: "sensor",
|
||||
entity: "sensor.outside_humidity",
|
||||
name: "Humidity",
|
||||
icon: "mdi:water",
|
||||
unit: "pct",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Without tap action (not focusable)",
|
||||
config: {
|
||||
type: "sensor",
|
||||
entity: "sensor.outside_temperature",
|
||||
tap_action: { action: "none" },
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Unavailable",
|
||||
config: {
|
||||
type: "sensor",
|
||||
entity: "sensor.not_working",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Invalid entity",
|
||||
config: {
|
||||
type: "sensor",
|
||||
entity: "sensor.invalid_entity",
|
||||
},
|
||||
},
|
||||
] satisfies DemoCardConfig<SensorCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-sensor-card")
|
||||
class DemoSensorCard extends LitElement {
|
||||
@query("#demos") private _demoRoot!: HTMLElement;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`<demo-cards id="demos" .configs=${CONFIGS}></demo-cards>`;
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProperties);
|
||||
const hass = provideHass(this._demoRoot);
|
||||
hass.updateTranslations(null, "en");
|
||||
hass.updateTranslations("lovelace", "en");
|
||||
hass.updateHass({
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [...hass.config.components, "history"],
|
||||
},
|
||||
});
|
||||
hass.addEntities(ENTITIES);
|
||||
mockIcons(hass);
|
||||
mockHistory(hass);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-lovelace-sensor-card": DemoSensorCard;
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ const ENTITIES = [
|
||||
attributes: {
|
||||
friendly_name: "Bed Light",
|
||||
supported_color_modes: [LightColorMode.HS, LightColorMode.COLOR_TEMP],
|
||||
color_mode: LightColorMode.HS,
|
||||
hs_color: [210, 60],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -275,6 +277,22 @@ const CONFIGS = [
|
||||
features: [{ type: "light-brightness" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Light color feature",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "light.bed_light",
|
||||
features: [{ type: "light-color" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Light color feature with hue and saturation",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "light.bed_light",
|
||||
features: [{ type: "light-color", controls: "hue_saturation" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Light color temperature feature",
|
||||
config: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import "../../../../src/components/ha-formfield";
|
||||
import "../../../../src/components/ha-select";
|
||||
import type { HaSelectSelectEvent } from "../../../../src/components/ha-select";
|
||||
import "../../../../src/components/ha-switch";
|
||||
@@ -388,13 +387,12 @@ export class DemoMiscCloudAccount
|
||||
|
||||
private _toggle(label: string, field: keyof CloudDemoScenario) {
|
||||
return html`
|
||||
<ha-formfield .label=${label}>
|
||||
<ha-switch
|
||||
.checked=${this._scenario[field] as boolean}
|
||||
data-field=${field}
|
||||
@change=${this._switchChanged}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
<ha-switch
|
||||
.checked=${this._scenario[field] as boolean}
|
||||
data-field=${field}
|
||||
@change=${this._switchChanged}
|
||||
>${label}</ha-switch
|
||||
>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import "../../../../src/components/ha-formfield";
|
||||
import "../../../../src/components/ha-switch";
|
||||
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -258,12 +257,10 @@ export class DemoIntegrationCard extends LitElement {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="filters">
|
||||
<ha-formfield label="Custom Integration">
|
||||
<ha-switch @change=${this._toggleCustomIntegration}></ha-switch>
|
||||
</ha-formfield>
|
||||
<ha-formfield label="Relies on cloud">
|
||||
<ha-switch @change=${this._toggleCloud}></ha-switch>
|
||||
</ha-formfield>
|
||||
<ha-switch @change=${this._toggleCustomIntegration}
|
||||
>Custom Integration</ha-switch
|
||||
>
|
||||
<ha-switch @change=${this._toggleCloud}>Relies on cloud</ha-switch>
|
||||
</div>
|
||||
|
||||
<ha-ignored-config-entry-card
|
||||
@@ -293,7 +290,6 @@ export class DemoIntegrationCard extends LitElement {
|
||||
class=${classMap({
|
||||
highlight: info.highlight !== undefined,
|
||||
})}
|
||||
.hass=${this.hass}
|
||||
domain="esphome"
|
||||
.items=${info.items}
|
||||
.manifest=${createManifest(
|
||||
@@ -316,7 +312,6 @@ export class DemoIntegrationCard extends LitElement {
|
||||
<!-- One that is standalone to see how it increases height if height
|
||||
not defined by other cards. -->
|
||||
<ha-integration-card
|
||||
.hass=${this.hass}
|
||||
domain="esphome"
|
||||
.items=${[
|
||||
loadedEntry,
|
||||
@@ -369,9 +364,9 @@ export class DemoIntegrationCard extends LitElement {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
ha-formfield {
|
||||
ha-switch {
|
||||
margin: 8px 0;
|
||||
display: block;
|
||||
display: flex;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
+39
-41
@@ -12,8 +12,9 @@
|
||||
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
|
||||
"lint:prettier": "prettier . --cache --check",
|
||||
"format:prettier": "prettier . --cache --write",
|
||||
"lint:types": "node ./node_modules/@typescript/native/bin/tsc",
|
||||
"lint:types": "node ./node_modules/@typescript/native/bin/tsc && node ./node_modules/@typescript/native/bin/tsc -p .github/scripts",
|
||||
"lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"",
|
||||
"lint:element-imports": "eslint \"**/src/**/*.{js,ts,html}\" --ignore-pattern=.gitignore --rule '{\"ha/no-unused-element-import\": \"warn\"}'",
|
||||
"lint:licenses": "node --no-deprecation script/check-licenses",
|
||||
"lint": "pnpm run lint:eslint && pnpm run lint:prettier && pnpm run lint:types && pnpm run lint:lit",
|
||||
"format": "pnpm run format:eslint && pnpm run format:prettier",
|
||||
@@ -39,20 +40,20 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "8.0.5",
|
||||
"@babel/runtime": "8.0.7",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.3",
|
||||
"@codemirror/commands": "6.11.1",
|
||||
"@codemirror/lang-jinja": "6.0.1",
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/language": "6.13.1",
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.2",
|
||||
"@codemirror/state": "6.7.6",
|
||||
"@codemirror/view": "6.43.13",
|
||||
"@codemirror/view": "6.43.14",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.8.0",
|
||||
"@formatjs/intl-datetimeformat": "7.8.1",
|
||||
"@formatjs/intl-displaynames": "7.3.15",
|
||||
"@formatjs/intl-durationformat": "0.11.1",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.12",
|
||||
@@ -67,36 +68,33 @@
|
||||
"@fullcalendar/list": "6.1.21",
|
||||
"@fullcalendar/luxon3": "6.1.21",
|
||||
"@fullcalendar/timegrid": "6.1.21",
|
||||
"@home-assistant/webawesome": "3.7.0-ha.1",
|
||||
"@home-assistant/webawesome": "3.14.0-ha.0",
|
||||
"@lezer/common": "1.5.3",
|
||||
"@lezer/highlight": "1.2.4",
|
||||
"@lezer/highlight": "1.2.5",
|
||||
"@lit-labs/motion": "1.1.0",
|
||||
"@lit-labs/observers": "2.1.0",
|
||||
"@lit-labs/virtualizer": "2.1.1",
|
||||
"@lit/context": "1.1.6",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@lit/task": "1.0.3",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@material/mwc-formfield": "0.27.0",
|
||||
"@material/mwc-list": "0.27.0",
|
||||
"@material/web": "2.5.0",
|
||||
"@mdi/js": "7.4.47",
|
||||
"@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": "6.0.1",
|
||||
"@versatiles/style": "6.4.0",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.198.0",
|
||||
"@vvo/tzdb": "6.201.1",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"barcode-detector": "3.2.2",
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.1",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.50.0",
|
||||
"cropperjs": "1.6.3",
|
||||
"cropperjs": "1.7.0",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.4.0",
|
||||
"deep-clone-simple": "1.1.1",
|
||||
@@ -106,19 +104,19 @@
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.7.3",
|
||||
"home-assistant-js-websocket": "9.7.0",
|
||||
"home-assistant-js-websocket": "9.7.1",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "12.1.2",
|
||||
"js-yaml": "5.4.2",
|
||||
"intl-messageformat": "12.1.3",
|
||||
"js-yaml": "5.4.3",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"maplibre-gl": "6.11.2",
|
||||
"marked": "18.0.14",
|
||||
"maplibre-gl": "6.14.0",
|
||||
"marked": "18.1.0",
|
||||
"memoize-one": "6.0.0",
|
||||
"nanoid": "6.0.1",
|
||||
"nanoid": "6.0.2",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
"punycode": "2.3.1",
|
||||
@@ -126,10 +124,10 @@
|
||||
"qrcode": "1.5.4",
|
||||
"roboto-fontface": "0.10.0",
|
||||
"rrule": "2.8.1",
|
||||
"sortablejs": "1.15.6",
|
||||
"sortablejs": "1.15.7",
|
||||
"stacktrace-js": "2.0.2",
|
||||
"superstruct": "2.0.2",
|
||||
"tinykeys": "4.0.0",
|
||||
"tinykeys": "4.0.1",
|
||||
"weekstart": "2.0.0",
|
||||
"workbox-cacheable-response": "7.4.1",
|
||||
"workbox-core": "7.4.1",
|
||||
@@ -139,26 +137,25 @@
|
||||
"workbox-strategies": "7.4.1",
|
||||
"xss": "1.0.15",
|
||||
"zrender": "6.1.0",
|
||||
"zxing-wasm": "3.1.3"
|
||||
"zxing-wasm": "3.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ampproject/remapping": "2.3.0",
|
||||
"@babel/core": "8.0.6",
|
||||
"@babel/helper-compilation-targets": "8.0.6",
|
||||
"@babel/core": "8.0.7",
|
||||
"@babel/helper-compilation-targets": "8.0.7",
|
||||
"@babel/helper-define-polyfill-provider": "1.0.0",
|
||||
"@babel/plugin-transform-runtime": "8.0.6",
|
||||
"@babel/preset-env": "8.0.6",
|
||||
"@babel/preset-env": "8.0.7",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.3",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@html-eslint/eslint-plugin": "0.66.1",
|
||||
"@lokalise/node-api": "16.6.0",
|
||||
"@octokit/auth-oauth-device": "8.0.5",
|
||||
"@octokit/plugin-retry": "8.1.1",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rsdoctor/rspack-plugin": "1.6.4",
|
||||
"@rspack/core": "2.2.7",
|
||||
"@playwright/test": "1.64.0",
|
||||
"@rsdoctor/rspack-plugin": "1.6.5",
|
||||
"@rspack/core": "2.2.8",
|
||||
"@rspack/dev-server": "2.2.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -170,19 +167,20 @@
|
||||
"@types/leaflet": "1.9.22",
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
"@types/lodash.merge": "4.6.9",
|
||||
"@types/luxon": "3.7.5",
|
||||
"@types/luxon": "3.7.6",
|
||||
"@types/node": "24.19.2",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@typescript/native": "npm:[email protected]",
|
||||
"@vitest/coverage-v8": "5.0.2",
|
||||
"@vitest/coverage-v8": "5.0.3",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist": "4.29.1",
|
||||
"browserslist": "4.29.3",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"core-js-compat": "3.50.0",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.11.0",
|
||||
"del": "9.0.1",
|
||||
"eslint": "10.12.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
@@ -194,13 +192,13 @@
|
||||
"fs-extra": "11.4.1",
|
||||
"generate-license-file": "4.2.5",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.12.0",
|
||||
"globals": "17.13.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "30.1.1",
|
||||
"jsdom": "30.1.2",
|
||||
"jszip": "3.10.2",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lightningcss": "1.33.0",
|
||||
@@ -211,7 +209,7 @@
|
||||
"map-stream": "0.0.7",
|
||||
"minify-literals": "2.2.0",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.9.9",
|
||||
"prettier": "3.9.10",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
"serve": "14.2.6",
|
||||
"sinon": "22.1.0",
|
||||
@@ -219,15 +217,15 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.70.1",
|
||||
"vite": "8.3.1",
|
||||
"typescript-eslint": "8.71.1",
|
||||
"vite": "8.3.4",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.0.2",
|
||||
"vitest": "5.0.3",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "7.4.1"
|
||||
},
|
||||
"packageManager": "[email protected]8.0",
|
||||
"packageManager": "pnpm@12.11.2",
|
||||
"volta": {
|
||||
"node": "24.21.0"
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
diff --git a/mwc-formfield-base.js b/mwc-formfield-base.js
|
||||
index 7b763326d7d51835ad52646bfbc80fe21989abd3..f2baa8224e6d03df1fdb0b9fd03f5c6d77fc8747 100644
|
||||
--- a/mwc-formfield-base.js
|
||||
+++ b/mwc-formfield-base.js
|
||||
@@ -9,7 +9,7 @@ import { BaseElement } from '@material/mwc-base/base-element.js';
|
||||
import { FormElement } from '@material/mwc-base/form-element.js';
|
||||
import { observer } from '@material/mwc-base/observer.js';
|
||||
import { html } from 'lit';
|
||||
-import { property, query, queryAssignedNodes } from 'lit/decorators.js';
|
||||
+import { property, query, queryAssignedElements } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
export class FormfieldBase extends BaseElement {
|
||||
constructor() {
|
||||
@@ -96,7 +96,7 @@ __decorate([
|
||||
query('.mdc-form-field')
|
||||
], FormfieldBase.prototype, "mdcRoot", void 0);
|
||||
__decorate([
|
||||
- queryAssignedNodes('', true, '*')
|
||||
+ queryAssignedElements({ slot: "", flatten: true, selector: "*" })
|
||||
], FormfieldBase.prototype, "slottedInputs", void 0);
|
||||
__decorate([
|
||||
query('label')
|
||||
File renamed without changes.
File renamed without changes.
Generated
+1289
-1089
File diff suppressed because it is too large.
Load diff
+6
-3
@@ -3,6 +3,10 @@ autoInstallPeers: false
|
||||
|
||||
# Supply-chain protection
|
||||
minimumReleaseAge: 4320 # 3 days, in minutes
|
||||
minimumReleaseAgeExclude:
|
||||
# Our own packages, published from our own CI. Keep in sync with renovate.json
|
||||
- home-assistant-js-websocket
|
||||
- "@home-assistant/*"
|
||||
trustPolicy: no-downgrade
|
||||
trustPolicyExclude:
|
||||
# Backports published after provenance-signed 7.x releases
|
||||
@@ -26,9 +30,8 @@ overrides:
|
||||
tslib: 2.8.1
|
||||
|
||||
patchedDependencies:
|
||||
"@material/[email protected]": patches/@[email protected]
|
||||
"@material/[email protected]": patches/@[email protected]
|
||||
[email protected]: patches/[email protected]
|
||||
[email protected].6: patches/[email protected].6.patch
|
||||
[email protected].0: patches/[email protected].0.patch
|
||||
[email protected].7: patches/[email protected].7.patch
|
||||
[email protected].1: patches/[email protected].1.patch
|
||||
[email protected]: patches/[email protected]
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 223 KiB |
@@ -144,6 +144,21 @@
|
||||
"matchPackageNames": ["/^actions\\/cache(?:\\/|$)/"],
|
||||
"groupName": "actions/cache",
|
||||
"groupSlug": "actions-cache"
|
||||
},
|
||||
{
|
||||
"description": "First-party packages and actions are reviewed and published by us, so skip the release delay. Keep in sync with minimumReleaseAgeExclude in pnpm-workspace.yaml",
|
||||
"matchPackageNames": [
|
||||
"home-assistant-js-websocket",
|
||||
"@home-assistant/**",
|
||||
"home-assistant/**"
|
||||
],
|
||||
"minimumReleaseAge": null
|
||||
},
|
||||
{
|
||||
"description": "Follow webawesome fork releases (X.Y.Z-ha.N) across upstream versions, and skip the plain upstream syncs",
|
||||
"matchPackageNames": ["@home-assistant/webawesome"],
|
||||
"allowedVersions": "/-ha\\.\\d+$/",
|
||||
"ignoreUnstable": false
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Safe bash settings
|
||||
# -e Exit on command fail
|
||||
# -u Exit on unset variable
|
||||
# -o pipefail Exit if piped command has error code
|
||||
set -eu -o pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
./node_modules/.bin/gulp gen-demo-core-data
|
||||
Loaded 100 of 972 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user