mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-19 12:19:32 +00:00
Compare commits
177
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 |
No files matched your search
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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/"));
|
||||
}
|
||||
|
||||
@@ -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)};
|
||||
`
|
||||
),
|
||||
]);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import "./entry-html.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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
+38
-13
@@ -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,6 +36,7 @@ import { mockHardware } from "./stubs/hardware";
|
||||
import { mockHassioSupervisor } from "./stubs/hassio_supervisor";
|
||||
import { mockIntegration } from "./stubs/integration";
|
||||
import { mockLabelRegistry } from "./stubs/label_registry";
|
||||
import { automationPlatformDomains } from "./stubs/automation_platform_domains";
|
||||
import { mockIcons } from "./stubs/icons";
|
||||
import { mockHistory } from "./stubs/history";
|
||||
import { mockLovelace } from "./stubs/lovelace";
|
||||
@@ -58,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",
|
||||
@@ -89,15 +106,19 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [
|
||||
...(hass.config?.components ?? []),
|
||||
"backup",
|
||||
"webhook",
|
||||
"usage_prediction",
|
||||
"assist_pipeline",
|
||||
"hassio",
|
||||
"hardware",
|
||||
"marketplace",
|
||||
...connectivityComponents,
|
||||
// Some automation platforms are loaded already
|
||||
...new Set([
|
||||
...(hass.config?.components ?? []),
|
||||
"backup",
|
||||
"webhook",
|
||||
"usage_prediction",
|
||||
"assist_pipeline",
|
||||
"hassio",
|
||||
"hardware",
|
||||
"marketplace",
|
||||
...connectivityComponents,
|
||||
...automationPlatformDomains,
|
||||
]),
|
||||
],
|
||||
},
|
||||
panels: {
|
||||
@@ -116,7 +137,9 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
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);
|
||||
@@ -151,6 +174,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
mockFloorRegistry(hass);
|
||||
mockLabelRegistry(hass);
|
||||
mockUsagePrediction(hass);
|
||||
mockEntityRegistryDisplay(hass);
|
||||
mockEntityRegistry(hass, [
|
||||
{
|
||||
config_entry_id: "co2signal",
|
||||
@@ -206,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 {
|
||||
|
||||
+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" },
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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**
|
||||
|
||||
|
||||
@@ -63,26 +63,25 @@ 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`. |
|
||||
| selectedSection | String | - | Section filter selected when the list is initialized. |
|
||||
| sectionTitleFunction | Function | - | Builds the sticky section title from the visible range. |
|
||||
| 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. |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ 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";
|
||||
@@ -1063,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];
|
||||
@@ -1162,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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
+24
-27
@@ -40,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",
|
||||
@@ -77,27 +77,24 @@
|
||||
"@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.1.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",
|
||||
@@ -109,17 +106,17 @@
|
||||
"hls.js": "1.7.3",
|
||||
"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.12.0",
|
||||
"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",
|
||||
@@ -140,15 +137,15 @@
|
||||
"workbox-strategies": "7.4.1",
|
||||
"xss": "1.0.15",
|
||||
"zrender": "6.1.0",
|
||||
"zxing-wasm": "3.1.4"
|
||||
"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",
|
||||
@@ -156,8 +153,8 @@
|
||||
"@lokalise/node-api": "16.6.0",
|
||||
"@octokit/auth-oauth-device": "8.0.5",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rsdoctor/rspack-plugin": "1.6.4",
|
||||
"@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",
|
||||
@@ -171,7 +168,7 @@
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
"@types/lodash.merge": "4.6.9",
|
||||
"@types/luxon": "3.7.6",
|
||||
"@types/node": "24.19.1",
|
||||
"@types/node": "24.19.2",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
@@ -182,7 +179,7 @@
|
||||
"browserslist": "4.29.3",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"core-js-compat": "3.50.0",
|
||||
"del": "8.0.1",
|
||||
"del": "9.0.1",
|
||||
"eslint": "10.12.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
@@ -212,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",
|
||||
@@ -220,15 +217,15 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.71.0",
|
||||
"vite": "8.3.2",
|
||||
"typescript-eslint": "8.71.1",
|
||||
"vite": "8.3.4",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.0.3",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "7.4.1"
|
||||
},
|
||||
"packageManager": "pnpm@12.9.1",
|
||||
"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')
|
||||
Generated
+800
-834
File diff suppressed because it is too large.
Load diff
@@ -30,7 +30,6 @@ 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]: 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 |
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
|
||||
@@ -39,3 +39,14 @@ export const hexBlend = (c1: string, c2: string, blend = 50): string => {
|
||||
}
|
||||
return `#${color}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a CSS color, like a color name, to `#rrggbb`.
|
||||
* @param color - Any CSS color (name, hex, rgb(), hsl() and so on).
|
||||
* @returns The hex color, or undefined for invalid colors and colors with
|
||||
* transparency.
|
||||
*/
|
||||
export const cssColorToHex = (color: string): string | undefined => {
|
||||
const parsed = parse(color.trim());
|
||||
return parsed && (parsed.alpha ?? 1) === 1 ? formatHex(parsed) : undefined;
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import { mainWindow } from "../dom/get_main_window";
|
||||
import { replaceCurrentUrl } from "../navigate";
|
||||
import { constructUrlCurrentPath } from "../url/construct-url";
|
||||
import { currentPath } from "../url/current-path";
|
||||
import {
|
||||
updateQueryString,
|
||||
type QueryParamConfig,
|
||||
type QueryParamValues,
|
||||
} from "../url/query-params";
|
||||
|
||||
const DEBOUNCE_WAIT = 300;
|
||||
|
||||
export interface QueryParamsControllerOptions {
|
||||
/** Whether the host keeps its state in the URL. */
|
||||
enabled: () => boolean;
|
||||
/**
|
||||
* Called when the page of the host is navigated to while the host is
|
||||
* connected, like a second link to an open page or coming back to it. Read
|
||||
* the URL with `searchParams()` and apply it, then `write()` the state back.
|
||||
*/
|
||||
navigated: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps part of a page's state in the query string of the URL, so it can be
|
||||
* shared, bookmarked and reloaded. Writes replace the current history entry,
|
||||
* so Back leaves the page instead of undoing each change, and only touch the
|
||||
* config's params. The controller only acts while the URL is still the
|
||||
* page the host was connected on.
|
||||
*/
|
||||
export class QueryParamsController<
|
||||
C extends QueryParamConfig,
|
||||
> implements ReactiveController {
|
||||
private _path?: string;
|
||||
|
||||
private _pending?: QueryParamValues<C>;
|
||||
|
||||
private _pendingTimeout?: number;
|
||||
|
||||
private _hostUpdated = false;
|
||||
|
||||
constructor(
|
||||
private _host: ReactiveControllerHost,
|
||||
private _config: C,
|
||||
private _options: QueryParamsControllerOptions
|
||||
) {
|
||||
this._host.addController(this);
|
||||
}
|
||||
|
||||
public hostConnected() {
|
||||
this._path = currentPath();
|
||||
mainWindow.addEventListener("location-changed", this._navigated);
|
||||
mainWindow.addEventListener("popstate", this._navigated);
|
||||
|
||||
// Before the first update the host reads the URL itself, after its
|
||||
// initial state is set
|
||||
if (this._hostUpdated) {
|
||||
this._navigated();
|
||||
}
|
||||
}
|
||||
|
||||
public hostUpdated() {
|
||||
this._hostUpdated = true;
|
||||
}
|
||||
|
||||
public hostDisconnected() {
|
||||
mainWindow.removeEventListener("location-changed", this._navigated);
|
||||
mainWindow.removeEventListener("popstate", this._navigated);
|
||||
this._cancelPending();
|
||||
}
|
||||
|
||||
/** The current search params, or undefined when not on the host's page. */
|
||||
public searchParams(): URLSearchParams | undefined {
|
||||
if (!this._isActive()) {
|
||||
return undefined;
|
||||
}
|
||||
return new URLSearchParams(mainWindow.location.search);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write values to the URL. Pass `debounced` for values that change while
|
||||
* typing; any later write replaces a pending one.
|
||||
*/
|
||||
public write(values: QueryParamValues<NoInfer<C>>, debounced = false) {
|
||||
this._cancelPending();
|
||||
if (debounced) {
|
||||
this._pending = values;
|
||||
this._pendingTimeout = window.setTimeout(
|
||||
() => this.flush(),
|
||||
DEBOUNCE_WAIT
|
||||
);
|
||||
return;
|
||||
}
|
||||
this._write(values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a pending debounced write now, like when typing ends, before
|
||||
* something like a dialog adds a history entry it would land on.
|
||||
*/
|
||||
public flush() {
|
||||
const values = this._pending;
|
||||
this._cancelPending();
|
||||
if (values) {
|
||||
this._write(values);
|
||||
}
|
||||
}
|
||||
|
||||
private _cancelPending() {
|
||||
clearTimeout(this._pendingTimeout);
|
||||
this._pendingTimeout = undefined;
|
||||
this._pending = undefined;
|
||||
}
|
||||
|
||||
private _isActive(): boolean {
|
||||
return this._options.enabled() && currentPath() === this._path;
|
||||
}
|
||||
|
||||
private _navigated = () => {
|
||||
this._cancelPending();
|
||||
// Leaving the page gets here too, before the router swaps the page
|
||||
if (this._isActive()) {
|
||||
this._options.navigated();
|
||||
}
|
||||
};
|
||||
|
||||
private _write(values: QueryParamValues<C>) {
|
||||
if (!this._isActive()) {
|
||||
return;
|
||||
}
|
||||
const current = mainWindow.location.search;
|
||||
const queryString = updateQueryString(current, values, this._config);
|
||||
if (current !== (queryString ? `?${queryString}` : "")) {
|
||||
replaceCurrentUrl(constructUrlCurrentPath(queryString));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export class ScrollFadeController {
|
||||
if (!this._element) {
|
||||
return;
|
||||
}
|
||||
const rtl = this._element.matches(":dir(rtl)");
|
||||
const rtl = getComputedStyle(this._element).direction === "rtl";
|
||||
const scrolled = rtl ? -this._element.scrollLeft : this._element.scrollLeft;
|
||||
const maxScroll = this._element.scrollWidth - this._element.clientWidth;
|
||||
const start = scrolled > 1;
|
||||
|
||||
@@ -13,6 +13,7 @@ export const canOverrideAlphanumericInput = (composedPath: EventTarget[]) => {
|
||||
|
||||
if (
|
||||
el.tagName === "TEXTAREA" ||
|
||||
(el as HTMLElement).isContentEditable ||
|
||||
el.parentElement?.tagName === "HA-SELECT" ||
|
||||
el.parentElement?.tagName === "HA-DROPDOWN"
|
||||
) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { capitalizeFirstLetter } from "../string/capitalize-first-letter";
|
||||
import { isDate } from "../string/is_date";
|
||||
import { isTimestamp } from "../string/is_timestamp";
|
||||
import { blankBeforeUnit } from "../translations/blank_before_unit";
|
||||
import type { LocalizeFunc } from "../translations/localize";
|
||||
import type { LocalizeFunc, LocalizeKeys } from "../translations/localize";
|
||||
import { computeDomain } from "./compute_domain";
|
||||
import { computeStateDomain } from "./compute_state_domain";
|
||||
|
||||
@@ -198,6 +198,8 @@ export const computeAttributeNameDisplay = (
|
||||
localize(
|
||||
`component.${domain}.entity_component._.state_attributes.${attribute}.name`
|
||||
) ||
|
||||
// Attributes every entity can have, which no integration translates
|
||||
localize(`ui.components.attributes.names.${attribute}` as LocalizeKeys) ||
|
||||
capitalizeFirstLetter(
|
||||
attribute
|
||||
.replace(/_/g, " ")
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
|
||||
import type {
|
||||
EntityRegistryDisplayEntry,
|
||||
EntityRegistryEntry,
|
||||
} from "../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { getDuplicates } from "../string/get_duplicates";
|
||||
import { computeStateName } from "./compute_state_name";
|
||||
|
||||
export const computeDeviceName = (
|
||||
device: DeviceRegistryEntry
|
||||
@@ -14,32 +9,15 @@ export const computeDeviceName = (
|
||||
|
||||
export const computeDeviceNameDisplay = (
|
||||
device: DeviceRegistryEntry,
|
||||
localize: HomeAssistant["localize"],
|
||||
hassStates: HomeAssistant["states"],
|
||||
entities?: EntityRegistryEntry[] | EntityRegistryDisplayEntry[] | string[]
|
||||
localize: HomeAssistant["localize"]
|
||||
) =>
|
||||
computeDeviceName(device) ||
|
||||
(entities && fallbackDeviceName(hassStates, entities)) ||
|
||||
localize("ui.panel.config.devices.unnamed_device", {
|
||||
type: localize(
|
||||
`ui.panel.config.devices.type.${device.entry_type || "device"}`
|
||||
),
|
||||
});
|
||||
|
||||
export const fallbackDeviceName = (
|
||||
hassStates: HomeAssistant["states"],
|
||||
entities: EntityRegistryEntry[] | EntityRegistryDisplayEntry[] | string[]
|
||||
) => {
|
||||
for (const entity of entities || []) {
|
||||
const entityId = typeof entity === "string" ? entity : entity.entity_id;
|
||||
const stateObj = hassStates[entityId];
|
||||
if (stateObj) {
|
||||
return computeStateName(stateObj);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getDuplicatedDeviceNames = memoizeOne(
|
||||
(devices: HomeAssistant["devices"]): Set<string> => {
|
||||
const names = Object.values(devices)
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type {
|
||||
EntityRegistryDisplayEntry,
|
||||
EntityRegistryEntry,
|
||||
} from "../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { computeDeviceName } from "./compute_device_name";
|
||||
import { computeStateName } from "./compute_state_name";
|
||||
import {
|
||||
isDeviceName,
|
||||
stripPrefixFromEntityName,
|
||||
} from "./strip_prefix_from_entity_name";
|
||||
|
||||
export const computeEntityName = (
|
||||
stateObj: HassEntity,
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"]
|
||||
): string | undefined => {
|
||||
const entry = entities[stateObj.entity_id] as
|
||||
EntityRegistryDisplayEntry | undefined;
|
||||
|
||||
if (!entry) {
|
||||
// Fall back to state name if not in the entity registry (friendly name)
|
||||
return computeStateName(stateObj);
|
||||
}
|
||||
return computeEntityEntryName(entry, devices);
|
||||
};
|
||||
|
||||
export const computeEntityEntryName = (
|
||||
entry: EntityRegistryDisplayEntry | EntityRegistryEntry,
|
||||
devices: HomeAssistant["devices"],
|
||||
fallbackStateObj?: HassEntity
|
||||
): string | undefined => {
|
||||
const name =
|
||||
entry.name ??
|
||||
("original_name" in entry && entry.original_name != null
|
||||
? String(entry.original_name)
|
||||
: undefined);
|
||||
|
||||
const device = entry.device_id ? devices[entry.device_id] : undefined;
|
||||
|
||||
if (!device) {
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
if (fallbackStateObj) {
|
||||
return computeStateName(fallbackStateObj);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const deviceName = computeDeviceName(device);
|
||||
|
||||
if (entry.name == null || entry.next_name_part !== "device") {
|
||||
return name || undefined;
|
||||
}
|
||||
|
||||
// An empty entity name or one that is only the device name means the entity uses the device name
|
||||
if (!name || (deviceName && isDeviceName(name, deviceName))) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Remove the device name from the entity name if it starts with it
|
||||
if (deviceName && name) {
|
||||
return stripPrefixFromEntityName(name, deviceName) || name;
|
||||
}
|
||||
|
||||
return name;
|
||||
};
|
||||
|
||||
export const entityUseDeviceName = (
|
||||
stateObj: HassEntity,
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"]
|
||||
): boolean => !computeEntityName(stateObj, entities, devices);
|
||||
@@ -3,19 +3,20 @@ import type {
|
||||
EntityRegistryDisplayEntry,
|
||||
EntityRegistryEntry,
|
||||
} from "../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HomeAssistant, HomeAssistantRegistries } from "../../types";
|
||||
import { ensureArray } from "../array/ensure-array";
|
||||
import { computeRTL } from "../util/compute_rtl";
|
||||
import { computeAreaName } from "./compute_area_name";
|
||||
import { computeDeviceName } from "./compute_device_name";
|
||||
import {
|
||||
computeEntityEntryName,
|
||||
entityUseDeviceName,
|
||||
} from "./compute_entity_name";
|
||||
import { computeFloorName } from "./compute_floor_name";
|
||||
import { computeStateName } from "./compute_state_name";
|
||||
import { getEntityEntryContext } from "./context/get_entity_context";
|
||||
import type { EntityNameItem, EntityNameOptions } from "./entity_name_config";
|
||||
import {
|
||||
DEFAULT_ENTITY_NAME,
|
||||
type EntityNameItem,
|
||||
type EntityNameOptions,
|
||||
type EntityNameType,
|
||||
} from "./entity_name_config";
|
||||
|
||||
const DEFAULT_SEPARATOR = " ";
|
||||
|
||||
@@ -26,6 +27,12 @@ export type {
|
||||
EntityNameType,
|
||||
} from "./entity_name_config";
|
||||
|
||||
export type EntityNameParts = Partial<Record<EntityNameType, string>>;
|
||||
|
||||
export interface EntityNamePartsOptions {
|
||||
followContext?: boolean;
|
||||
}
|
||||
|
||||
// Joins items that need no entity context. Returns undefined as soon as one
|
||||
// item references the registries (device, area, ...).
|
||||
const computeTextOnlyName = (
|
||||
@@ -47,7 +54,7 @@ const computeTextOnlyName = (
|
||||
*/
|
||||
export const computeEntityNameDisplayWithoutContext = (
|
||||
stateObj: HassEntity,
|
||||
name: string | EntityNameItem | EntityNameItem[] | undefined,
|
||||
name?: string | EntityNameItem | EntityNameItem[],
|
||||
options?: EntityNameOptions
|
||||
): string => {
|
||||
if (typeof name === "string") {
|
||||
@@ -62,183 +69,140 @@ export const computeEntityNameDisplayWithoutContext = (
|
||||
);
|
||||
};
|
||||
|
||||
export const computeEntityNameDisplay = (
|
||||
stateObj: HassEntity,
|
||||
name: string | EntityNameItem | EntityNameItem[] | undefined,
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"],
|
||||
options?: EntityNameOptions
|
||||
) => {
|
||||
if (typeof name === "string") {
|
||||
return name;
|
||||
}
|
||||
|
||||
// If no name config is provided, fall back to the friendly name
|
||||
if (!name) {
|
||||
return computeStateName(stateObj);
|
||||
}
|
||||
|
||||
let items = ensureArray(name);
|
||||
|
||||
const separator = options?.separator ?? DEFAULT_SEPARATOR;
|
||||
|
||||
// If all items are text, just join them
|
||||
const textOnlyName = computeTextOnlyName(items, options);
|
||||
if (textOnlyName !== undefined) {
|
||||
return textOnlyName;
|
||||
}
|
||||
|
||||
const useDeviceName = entityUseDeviceName(stateObj, entities, devices);
|
||||
|
||||
// If entity uses device name, and device is not already included, replace it with device name
|
||||
if (useDeviceName) {
|
||||
const hasDevice = items.some((n) => n.type === "device");
|
||||
if (!hasDevice) {
|
||||
items = items.map((n) => (n.type === "entity" ? { type: "device" } : n));
|
||||
}
|
||||
}
|
||||
|
||||
const names = computeEntityNameList(
|
||||
stateObj,
|
||||
items,
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors,
|
||||
{ keepAllParts: true }
|
||||
);
|
||||
|
||||
// If after processing there is only one name, return that
|
||||
if (names.length === 1) {
|
||||
return names[0] || "";
|
||||
}
|
||||
|
||||
return names.filter((n) => n).join(separator);
|
||||
export const computeEntityEntryName = (
|
||||
entry: EntityRegistryDisplayEntry | EntityRegistryEntry
|
||||
): string | undefined => {
|
||||
const name =
|
||||
entry.name ??
|
||||
("original_name" in entry && entry.original_name != null
|
||||
? String(entry.original_name)
|
||||
: undefined);
|
||||
return name || undefined;
|
||||
};
|
||||
|
||||
export interface EntityNameListOptions {
|
||||
keepAllParts?: boolean;
|
||||
}
|
||||
|
||||
export const computeEntityNameList = (
|
||||
stateObj: HassEntity,
|
||||
name: EntityNameItem[],
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"],
|
||||
options?: EntityNameListOptions
|
||||
): (string | undefined)[] => {
|
||||
const entry = entities[stateObj.entity_id] as
|
||||
EntityRegistryDisplayEntry | undefined;
|
||||
|
||||
if (!entry) {
|
||||
return name.map((item) =>
|
||||
item.type === "entity"
|
||||
? computeStateName(stateObj)
|
||||
: item.type === "text"
|
||||
? item.text
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
return computeEntityEntryNameList(
|
||||
entry,
|
||||
name,
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors,
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
export const computeEntityEntryNameList = (
|
||||
export const computeEntityEntryNameParts = (
|
||||
entry: EntityRegistryDisplayEntry | EntityRegistryEntry,
|
||||
name: EntityNameItem[],
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"],
|
||||
options?: EntityNameListOptions
|
||||
): (string | undefined)[] => {
|
||||
registries: HomeAssistantRegistries,
|
||||
options?: EntityNamePartsOptions
|
||||
): EntityNameParts => {
|
||||
const { device, parentDevice, area, floor } = getEntityEntryContext(
|
||||
entry,
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
registries.entities,
|
||||
registries.devices,
|
||||
registries.areas,
|
||||
registries.floors
|
||||
);
|
||||
const entityName = computeEntityEntryName(entry, devices);
|
||||
const keepAllParts = options?.keepAllParts ?? false;
|
||||
const entityName = computeEntityEntryName(entry);
|
||||
const followContext = options?.followContext ?? true;
|
||||
|
||||
// Same rule as the backend: an owner only adds its name part while the
|
||||
// next_name_part links reach it, so owners above the first node with its own
|
||||
// area are left out. An entity without a name of its own is still named
|
||||
// after its device.
|
||||
const nameDevice =
|
||||
keepAllParts || entry.next_name_part === "device" || !entityName
|
||||
!followContext || entry.next_name_part === "device" || !entityName
|
||||
? device
|
||||
: null;
|
||||
const nameParentDevice =
|
||||
keepAllParts ||
|
||||
!followContext ||
|
||||
(entry.next_name_part === "device" &&
|
||||
device?.next_name_part === "parent_device")
|
||||
? parentDevice
|
||||
: null;
|
||||
|
||||
return name.map((item) => {
|
||||
switch (item.type) {
|
||||
case "entity":
|
||||
return entityName;
|
||||
case "device":
|
||||
return nameDevice ? computeDeviceName(nameDevice) : undefined;
|
||||
case "parent_device":
|
||||
return nameParentDevice
|
||||
? computeDeviceName(nameParentDevice)
|
||||
: undefined;
|
||||
case "area":
|
||||
return area ? computeAreaName(area) : undefined;
|
||||
case "floor":
|
||||
return floor ? computeFloorName(floor) : undefined;
|
||||
case "text":
|
||||
return item.text;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
return {
|
||||
entity: entityName,
|
||||
device: nameDevice ? computeDeviceName(nameDevice) : undefined,
|
||||
parent_device: nameParentDevice
|
||||
? computeDeviceName(nameParentDevice)
|
||||
: undefined,
|
||||
area: area ? computeAreaName(area) : undefined,
|
||||
floor: floor ? computeFloorName(floor) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const computeEntityNameParts = (
|
||||
stateObj: HassEntity,
|
||||
registries: HomeAssistantRegistries,
|
||||
options?: EntityNamePartsOptions
|
||||
): EntityNameParts => {
|
||||
const entry = registries.entities[stateObj.entity_id] as
|
||||
EntityRegistryDisplayEntry | undefined;
|
||||
|
||||
if (!entry) {
|
||||
return { entity: computeStateName(stateObj) };
|
||||
}
|
||||
return computeEntityEntryNameParts(entry, registries, options);
|
||||
};
|
||||
|
||||
export const computeDefaultEntityNameItems = (
|
||||
stateObj: HassEntity,
|
||||
registries: HomeAssistantRegistries
|
||||
): EntityNameItem[] => {
|
||||
const parts = computeEntityNameParts(stateObj, registries);
|
||||
return DEFAULT_ENTITY_NAME.filter((item) => parts[item.type]);
|
||||
};
|
||||
|
||||
export const computeEntityNameDisplay = (
|
||||
stateObj: HassEntity,
|
||||
name: string | EntityNameItem | EntityNameItem[] | undefined,
|
||||
registries: HomeAssistantRegistries,
|
||||
options?: EntityNameOptions
|
||||
): string => {
|
||||
if (typeof name === "string") {
|
||||
return name;
|
||||
}
|
||||
|
||||
const separator = options?.separator ?? DEFAULT_SEPARATOR;
|
||||
|
||||
if (!name) {
|
||||
const parts = computeEntityNameParts(stateObj, registries);
|
||||
return (
|
||||
DEFAULT_ENTITY_NAME.map((item) => parts[item.type])
|
||||
.filter((n) => n)
|
||||
.join(separator) || computeStateName(stateObj)
|
||||
);
|
||||
}
|
||||
|
||||
const items = ensureArray(name);
|
||||
|
||||
const textOnlyName = computeTextOnlyName(items, options);
|
||||
if (textOnlyName !== undefined) {
|
||||
return textOnlyName;
|
||||
}
|
||||
|
||||
const parts = computeEntityNameParts(stateObj, registries, {
|
||||
followContext: false,
|
||||
});
|
||||
|
||||
// An entity without a name of its own is named after its device, unless the
|
||||
// device is already part of the name
|
||||
const useDeviceName =
|
||||
!parts.entity && !items.some((item) => item.type === "device");
|
||||
|
||||
return items
|
||||
.map((item) =>
|
||||
item.type === "text"
|
||||
? item.text
|
||||
: parts[item.type === "entity" && useDeviceName ? "device" : item.type]
|
||||
)
|
||||
.filter((n) => n)
|
||||
.join(separator);
|
||||
};
|
||||
|
||||
export const computeEntitySearchLabels = (
|
||||
stateObj: HassEntity,
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"]
|
||||
registries: HomeAssistantRegistries
|
||||
) => {
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors,
|
||||
{ keepAllParts: true }
|
||||
);
|
||||
const parts = computeEntityNameParts(stateObj, registries, {
|
||||
followContext: false,
|
||||
});
|
||||
return {
|
||||
entityName: entityName || null,
|
||||
entityName: parts.entity || null,
|
||||
friendlyName: computeStateName(stateObj) || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
deviceName: parts.device || null,
|
||||
parentDeviceName: parts.parent_device || null,
|
||||
areaName: parts.area || null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -248,40 +212,20 @@ export interface EntityPickerDisplay {
|
||||
}
|
||||
|
||||
export const computeEntityPickerDisplay = (
|
||||
hass: Pick<
|
||||
HomeAssistant,
|
||||
| "entities"
|
||||
| "devices"
|
||||
| "areas"
|
||||
| "floors"
|
||||
| "language"
|
||||
| "translationMetadata"
|
||||
>,
|
||||
hass: HomeAssistantRegistries &
|
||||
Pick<HomeAssistant, "language" | "translationMetadata">,
|
||||
stateObj: HassEntity
|
||||
): EntityPickerDisplay => {
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const parts = computeEntityNameParts(stateObj, hass);
|
||||
|
||||
const isRTL = computeRTL(
|
||||
hass.language,
|
||||
hass.translationMetadata.translations
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || stateObj.entity_id;
|
||||
const primary = parts.entity || parts.device || stateObj.entity_id;
|
||||
const secondary =
|
||||
[areaName, parentDeviceName, entityName ? deviceName : undefined]
|
||||
[parts.area, parts.parent_device, parts.entity ? parts.device : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ") || undefined;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { HomeAssistant } from "../../types";
|
||||
import { stringCompare } from "../string/compare";
|
||||
import { computeDomain } from "./compute_domain";
|
||||
import { computeStateDomain } from "./compute_state_domain";
|
||||
import { computeZoneTrackerState } from "../../data/zone";
|
||||
|
||||
export const FIXED_DOMAIN_STATES = {
|
||||
alarm_control_panel: [
|
||||
@@ -275,12 +276,10 @@ export const getStatesDomain = (
|
||||
result.push(
|
||||
...Object.entries(hass.states)
|
||||
.filter(
|
||||
([entityId, stateObj]) =>
|
||||
computeDomain(entityId) === "zone" &&
|
||||
entityId !== "zone.home" &&
|
||||
stateObj.attributes.friendly_name
|
||||
([entityId]) =>
|
||||
computeDomain(entityId) === "zone" && entityId !== "zone.home"
|
||||
)
|
||||
.map(([_entityId, stateObj]) => stateObj.attributes.friendly_name!)
|
||||
.map(([_entityId, stateObj]) => computeZoneTrackerState(stateObj))
|
||||
.sort((zone1, zone2) =>
|
||||
stringCompare(zone1, zone2, hass.locale.language)
|
||||
)
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Sort function to help sort states by name
|
||||
*
|
||||
* Usage:
|
||||
* const states = [state1, state2]
|
||||
* states.sort(statessortStatesByName);
|
||||
*/
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { computeStateName } from "./compute_state_name";
|
||||
|
||||
export const sortStatesByName = (entityA: HassEntity, entityB: HassEntity) => {
|
||||
const nameA = computeStateName(entityA);
|
||||
const nameB = computeStateName(entityB);
|
||||
if (nameA < nameB) {
|
||||
return -1;
|
||||
}
|
||||
if (nameA > nameB) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
const SUFFIXES = [" ", ": ", " - "];
|
||||
|
||||
/**
|
||||
* Strips a device name from an entity name.
|
||||
* @param entityName the entity name
|
||||
* @param prefix the prefix to strip
|
||||
* @returns
|
||||
*/
|
||||
export const stripPrefixFromEntityName = (
|
||||
entityName: string,
|
||||
prefix: string
|
||||
) => {
|
||||
const lowerCasedEntityName = entityName.toLowerCase();
|
||||
const lowerCasedPrefix = prefix.toLowerCase();
|
||||
for (const suffix of SUFFIXES) {
|
||||
const lowerCasedPrefixWithSuffix = `${lowerCasedPrefix}${suffix}`;
|
||||
|
||||
if (lowerCasedEntityName.startsWith(lowerCasedPrefixWithSuffix)) {
|
||||
const newName = entityName.substring(lowerCasedPrefixWithSuffix.length);
|
||||
if (newName.length) {
|
||||
// If first word already has an upper case letter (e.g. from brand name)
|
||||
// leave as-is, otherwise capitalize the first word.
|
||||
return hasUpperCase(newName.substr(0, newName.indexOf(" ")))
|
||||
? newName
|
||||
: newName[0].toUpperCase() + newName.slice(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether an entity name is only the device name, which core treats as no name of its own.
|
||||
*/
|
||||
export const isDeviceName = (entityName: string, deviceName: string) => {
|
||||
if (!deviceName) {
|
||||
return false;
|
||||
}
|
||||
const lowerCasedEntityName = entityName.toLowerCase();
|
||||
const lowerCasedDeviceName = deviceName.toLowerCase();
|
||||
return (
|
||||
lowerCasedEntityName.startsWith(lowerCasedDeviceName) &&
|
||||
/^[ :-]*$/.test(lowerCasedEntityName.slice(lowerCasedDeviceName.length))
|
||||
);
|
||||
};
|
||||
|
||||
const hasUpperCase = (str: string): boolean => str.toLowerCase() !== str;
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
|
||||
import type { Map as LeafletMap, TileLayerOptions } from "leaflet";
|
||||
import type {
|
||||
setRTLTextPlugin,
|
||||
setWorkerUrl,
|
||||
StyleSpecification,
|
||||
} from "maplibre-gl";
|
||||
import type { setWorkerUrl, StyleSpecification } from "maplibre-gl";
|
||||
import { deepEqual } from "../util/deep-equal";
|
||||
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
|
||||
import type { ResolvedMapStyle } from "./map-styles";
|
||||
@@ -24,10 +20,6 @@ const SHIPPED_STYLES = {
|
||||
dark: "/static/map/dark.json",
|
||||
} as const;
|
||||
|
||||
// Without it Arabic and Hebrew labels render reversed. Loaded by MapLibre's
|
||||
// worker, hence a URL rather than an import.
|
||||
export const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
|
||||
|
||||
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
|
||||
// OSM serves no @2x variant.
|
||||
const RASTER_TILE_URL = `${MAP_TILES_PATH}/raster/{z}/{x}/{y}.png?token={token}`;
|
||||
@@ -162,20 +154,6 @@ export const ensureWorkerUrl = (setUrl: typeof setWorkerUrl) => {
|
||||
setUrl(new URL(__MAPLIBRE_WORKER_URL__, location.href).href);
|
||||
};
|
||||
|
||||
// Global to MapLibre, and it throws when set twice.
|
||||
let rtlTextPluginRequested = false;
|
||||
export const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
|
||||
if (rtlTextPluginRequested) {
|
||||
return;
|
||||
}
|
||||
rtlTextPluginRequested = true;
|
||||
setPlugin(new URL(RTL_TEXT_PLUGIN_URL, location.href).href, true).catch(
|
||||
() => {
|
||||
// RTL labels stay reversed; everything else still renders.
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const createVectorLayer = async (
|
||||
createLayer: typeof maplibreGL,
|
||||
leaflet: LeafletModuleType,
|
||||
@@ -390,7 +368,6 @@ export const createBaseLayer = async (
|
||||
import("maplibre-gl"),
|
||||
]);
|
||||
ensureWorkerUrl(maplibre.setWorkerUrl);
|
||||
ensureRTLTextPlugin(maplibre.setRTLTextPlugin);
|
||||
vectorLayer = await createVectorLayer(
|
||||
createLayer,
|
||||
leaflet,
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from "../marker-accessibility";
|
||||
import {
|
||||
CONTEXT_RESTORE_GRACE,
|
||||
ensureRTLTextPlugin,
|
||||
ensureWorkerUrl,
|
||||
loadStyle,
|
||||
MAP_MAX_ZOOM,
|
||||
@@ -320,7 +319,6 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
const maplibre = await import("maplibre-gl");
|
||||
this._maplibre = maplibre;
|
||||
ensureWorkerUrl(maplibre.setWorkerUrl);
|
||||
ensureRTLTextPlugin(maplibre.setRTLTextPlugin);
|
||||
|
||||
// MapLibre's stylesheet for controls and popups; one link per root
|
||||
const root = container.parentNode;
|
||||
@@ -702,6 +700,35 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
return;
|
||||
}
|
||||
const map = this._map!;
|
||||
if ("center" in fit) {
|
||||
// cameraForBounds cannot solve a zero-area bound; center on it instead
|
||||
if (this._clusterOptions) {
|
||||
this._groupingZoom = fit.zoom;
|
||||
try {
|
||||
this._rebuildClusters(true);
|
||||
} finally {
|
||||
this._groupingZoom = undefined;
|
||||
}
|
||||
}
|
||||
// Padding as an offset, not map padding, which would stick
|
||||
const { top, right, bottom, left } = fit.padding;
|
||||
const camera = {
|
||||
center: [fit.center[1], fit.center[0]] as [number, number],
|
||||
zoom: fit.zoom,
|
||||
offset: [(left - right) / 2, (top - bottom) / 2] as [number, number],
|
||||
animate: options?.animate,
|
||||
};
|
||||
// flyTo drops the offset under reduced motion (it jumps); easeTo keeps it
|
||||
if (
|
||||
options?.fly &&
|
||||
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
map.flyTo(camera);
|
||||
} else {
|
||||
map.easeTo(camera);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Regroup now for the zoom the fit lands on
|
||||
const zoom = this._clusterOptions
|
||||
? map.cameraForBounds(fit.bounds, fit.options)?.zoom
|
||||
@@ -721,7 +748,23 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
});
|
||||
}
|
||||
|
||||
private _fitFor(points: MapLatLng[], options?: MapFitOptions) {
|
||||
private _fitFor(
|
||||
points: MapLatLng[],
|
||||
options?: MapFitOptions
|
||||
):
|
||||
| {
|
||||
center: MapLatLng;
|
||||
zoom: number;
|
||||
padding: { top: number; right: number; bottom: number; left: number };
|
||||
}
|
||||
| {
|
||||
bounds: [[number, number], [number, number]];
|
||||
options: {
|
||||
maxZoom: number | undefined;
|
||||
padding: { top: number; right: number; bottom: number; left: number };
|
||||
};
|
||||
}
|
||||
| undefined {
|
||||
if (!this._map || !this._maplibre || !points.length) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -749,11 +792,9 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
if (minLat === maxLat && minLng === maxLng) {
|
||||
// Zero-area bounds: center on the point, keeping the zoom unless given
|
||||
return {
|
||||
bounds: [
|
||||
[minLng, minLat],
|
||||
[minLng, minLat],
|
||||
] as [[number, number], [number, number]],
|
||||
options: { maxZoom: maxZoom ?? this._map.getZoom(), padding },
|
||||
center: [minLat, minLng] as MapLatLng,
|
||||
zoom: maxZoom ?? this._map.getZoom(),
|
||||
padding,
|
||||
};
|
||||
}
|
||||
const pad = options?.pad ?? 0.5;
|
||||
|
||||
@@ -59,7 +59,7 @@ const NAME_EN: ExpressionSpecification = ["get", "name_en"];
|
||||
|
||||
// Strings compare by code point: anything from Basic Latin up to Latin
|
||||
// Extended-B, digits and punctuation included.
|
||||
const IS_LATIN: ExpressionSpecification = ["<", NAME, "ɐ"];
|
||||
const IS_LATIN: ExpressionSpecification = ["<", ["coalesce", NAME, ""], "ɐ"];
|
||||
|
||||
const ENGLISH_SCALE = 0.8;
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { theme2hex } from "../color/convert-color";
|
||||
/** The zone marker: a colored circle with the zone's icon or initials, shared by the map and the zone editor */
|
||||
|
||||
export const ZONE_CIRCLE_SIZE = 36;
|
||||
// Same as an entity marker's icon
|
||||
const ZONE_ICON_SIZE = 24;
|
||||
|
||||
// WCAG minimum contrast for icons and other graphics
|
||||
const MIN_CONTENT_CONTRAST = 3;
|
||||
@@ -68,6 +70,6 @@ export const zoneMarkerStyles = `
|
||||
overflow: hidden;
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
--mdc-icon-size: ${ZONE_CIRCLE_SIZE / 2}px;
|
||||
--mdc-icon-size: ${ZONE_ICON_SIZE}px;
|
||||
}
|
||||
`;
|
||||
@@ -33,7 +33,7 @@ export type FormatEntityAttributeNameFunc = (
|
||||
|
||||
export type FormatEntityNameFunc = (
|
||||
stateObj: HassEntity,
|
||||
name: string | EntityNameItem | EntityNameItem[] | undefined,
|
||||
name?: string | EntityNameItem | EntityNameItem[],
|
||||
options?: EntityNameOptions
|
||||
) => string;
|
||||
|
||||
@@ -92,10 +92,7 @@ export const computeFormatFunctions = async (
|
||||
computeEntityNameDisplay(
|
||||
stateObj,
|
||||
name,
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors,
|
||||
{ entities, devices, areas, floors },
|
||||
options
|
||||
),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
SortingDirection,
|
||||
} from "../../components/data-table/ha-data-table";
|
||||
import {
|
||||
decodeQueryParams,
|
||||
hasQueryParams,
|
||||
type QueryParamConfig,
|
||||
type QueryParamValues,
|
||||
type SearchParamsSource,
|
||||
} from "./query-params";
|
||||
|
||||
export const dataTableQueryParamConfig = {
|
||||
string: ["search", "sort", "direction", "group"],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type DataTableQueryParams = QueryParamValues<
|
||||
typeof dataTableQueryParamConfig
|
||||
>;
|
||||
|
||||
// How a data table is searched, sorted and grouped
|
||||
export interface DataTableQueryState {
|
||||
search: string;
|
||||
sorting?: { column: string; direction: "asc" | "desc" };
|
||||
groupColumn?: string;
|
||||
}
|
||||
|
||||
// Sorting uses the column id unless the column has a name for the URL
|
||||
const sortName = (columns: DataTableColumnContainer, columnId: string) =>
|
||||
columns[columnId].urlName ?? columnId;
|
||||
|
||||
/**
|
||||
* Params for the table's state. Empty and default values are left out, and
|
||||
* grouping is only written for columns that have a name for the URL, so
|
||||
* internal column ids don't end up in links.
|
||||
*/
|
||||
export const dataTableQueryParamsFromState = (
|
||||
columns: DataTableColumnContainer,
|
||||
search: string,
|
||||
sortColumn: string | undefined,
|
||||
sortDirection: SortingDirection,
|
||||
groupColumn: string | undefined
|
||||
): DataTableQueryParams => {
|
||||
const params: DataTableQueryParams = {};
|
||||
if (search) {
|
||||
params.search = search;
|
||||
}
|
||||
if (sortColumn && sortDirection && columns[sortColumn]?.sortable) {
|
||||
params.sort = sortName(columns, sortColumn);
|
||||
if (sortDirection === "desc") {
|
||||
params.direction = "desc";
|
||||
}
|
||||
}
|
||||
if (groupColumn && columns[groupColumn]?.groupable) {
|
||||
const group = columns[groupColumn].urlName;
|
||||
if (group) {
|
||||
params.group = group;
|
||||
}
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
/**
|
||||
* The table's state a link asks for, undefined when it has no table params so
|
||||
* what was picked before stays. A link replaces the search, a search left
|
||||
* behind would hide part of what it shows. Sorting and grouping it leaves out,
|
||||
* or that don't match a column, keep what was picked before.
|
||||
*/
|
||||
export const dataTableQueryStateFromUrl = (
|
||||
searchParams: SearchParamsSource,
|
||||
columns: DataTableColumnContainer
|
||||
): DataTableQueryState | undefined => {
|
||||
if (!hasQueryParams(searchParams, dataTableQueryParamConfig)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { search, sort, direction, group } = decodeQueryParams(
|
||||
searchParams,
|
||||
dataTableQueryParamConfig
|
||||
);
|
||||
|
||||
const state: DataTableQueryState = { search: search ?? "" };
|
||||
|
||||
const sortColumn = sort
|
||||
? Object.keys(columns).find(
|
||||
(id) => columns[id].sortable && sortName(columns, id) === sort
|
||||
)
|
||||
: undefined;
|
||||
if (sortColumn) {
|
||||
state.sorting = {
|
||||
column: sortColumn,
|
||||
direction: direction === "desc" ? "desc" : "asc",
|
||||
};
|
||||
}
|
||||
|
||||
const groupColumn = group
|
||||
? Object.keys(columns).find(
|
||||
(id) => columns[id].groupable && columns[id].urlName === group
|
||||
)
|
||||
: undefined;
|
||||
if (groupColumn) {
|
||||
state.groupColumn = groupColumn;
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
createQueryString,
|
||||
decodeQueryParams,
|
||||
type QueryParamConfig,
|
||||
type QueryParamValues,
|
||||
type SearchParamsSource,
|
||||
} from "./query-params";
|
||||
|
||||
// The filters of the Marketplace tables. Search, sorting and grouping are kept
|
||||
// by the data table, see data-table-query-params.
|
||||
export const marketplaceFilterQueryParamConfig = {
|
||||
list: ["status", "type", "source"],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type MarketplaceFilterQueryParams = QueryParamValues<
|
||||
typeof marketplaceFilterQueryParamConfig
|
||||
>;
|
||||
|
||||
export const decodeMarketplaceFilterQueryParams = (
|
||||
searchParams: SearchParamsSource
|
||||
): MarketplaceFilterQueryParams =>
|
||||
decodeQueryParams(searchParams, marketplaceFilterQueryParamConfig);
|
||||
|
||||
export const createMarketplaceFilterQueryString = (
|
||||
values: MarketplaceFilterQueryParams
|
||||
): string => createQueryString(values, marketplaceFilterQueryParamConfig);
|
||||
@@ -134,6 +134,51 @@ export function createQueryString(
|
||||
return searchParams.toString();
|
||||
}
|
||||
|
||||
const queryParamKeys = (config: QueryParamConfig): string[] => [
|
||||
...(config.list ?? []),
|
||||
...(config.date ?? []),
|
||||
...(config.boolean ?? []).map(({ key }) => key),
|
||||
...(config.string ?? []),
|
||||
];
|
||||
|
||||
/** Whether the search params hold any of the config's params. */
|
||||
export const hasQueryParams = (
|
||||
searchParams: SearchParamsSource,
|
||||
config: QueryParamConfig
|
||||
): boolean =>
|
||||
queryParamKeys(config).some(
|
||||
(key) => getSearchParam(searchParams, key) !== null
|
||||
);
|
||||
|
||||
/**
|
||||
* Replace the config's params in a query string with the given values,
|
||||
* keeping every other param exactly as it is written.
|
||||
*/
|
||||
export function updateQueryString<C extends QueryParamConfig>(
|
||||
current: string,
|
||||
values: QueryParamValues<NoInfer<C>>,
|
||||
config: C
|
||||
): string;
|
||||
export function updateQueryString(
|
||||
current: string,
|
||||
values: Record<string, QueryParamValue | undefined>,
|
||||
config: QueryParamConfig
|
||||
): string {
|
||||
const keys = queryParamKeys(config);
|
||||
const kept = current
|
||||
.replace(/^\?/, "")
|
||||
.split("&")
|
||||
.filter((part) => {
|
||||
if (!part) {
|
||||
return false;
|
||||
}
|
||||
const [key] = new URLSearchParams(part).keys();
|
||||
return !keys.includes(key);
|
||||
});
|
||||
const updated = createQueryString(values, config);
|
||||
return [...kept, ...(updated ? [updated] : [])].join("&");
|
||||
}
|
||||
|
||||
export const serviceTargetFromQueryParams = <
|
||||
Key extends keyof HassServiceTarget & string,
|
||||
>(
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
export interface DebugSafeArea {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
highlight: boolean;
|
||||
}
|
||||
|
||||
export const DEBUG_SAFE_AREA_TOOL_STORAGE_KEY = "debugSafeAreaTool";
|
||||
|
||||
export const DEBUG_SAFE_AREA_STORAGE_KEY = "debugSafeArea";
|
||||
|
||||
const STYLE_ID = "ha-debug-safe-area";
|
||||
|
||||
export const applyDebugSafeArea = (config?: DebugSafeArea): void => {
|
||||
let style = document.getElementById(STYLE_ID);
|
||||
if (
|
||||
!config ||
|
||||
!(config.top || config.right || config.bottom || config.left)
|
||||
) {
|
||||
style?.remove();
|
||||
return;
|
||||
}
|
||||
if (!style) {
|
||||
style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
document.head.append(style);
|
||||
}
|
||||
const { top, right, bottom, left, highlight } = config;
|
||||
// Important wins over the insets the companion app sets
|
||||
style.textContent = `
|
||||
:root {
|
||||
--app-safe-area-inset-top: ${top}px !important;
|
||||
--app-safe-area-inset-right: ${right}px !important;
|
||||
--app-safe-area-inset-bottom: ${bottom}px !important;
|
||||
--app-safe-area-inset-left: ${left}px !important;
|
||||
}
|
||||
${
|
||||
highlight
|
||||
? `html::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
box-sizing: border-box;
|
||||
border: solid rgba(255, 0, 0, 0.25);
|
||||
border-width: ${top}px ${right}px ${bottom}px ${left}px;
|
||||
pointer-events: none;
|
||||
z-index: 2147483646;
|
||||
}`
|
||||
: ""
|
||||
}
|
||||
`;
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Keeps a locally edited value from being overwritten by entity state updates
|
||||
* while the change is waiting to be sent or the service call is in flight.
|
||||
*
|
||||
* Call `schedule()` when the user changes the value and a (debounced) service
|
||||
* call is queued, wrap the service call in `run()`, and only copy entity state
|
||||
* into the local value when `shouldSync()` returns true. After a successful
|
||||
* call the local value is kept until the next state update syncs it. When the
|
||||
* call fails, the `resync` callback restores the entity state.
|
||||
*
|
||||
* Only the latest call is tracked, as it supersedes any earlier ones.
|
||||
*/
|
||||
export class PendingValueSync {
|
||||
private _scheduled = false;
|
||||
|
||||
private _latestCall?: symbol;
|
||||
|
||||
public get pending(): boolean {
|
||||
return this._scheduled || this._latestCall !== undefined;
|
||||
}
|
||||
|
||||
/** Mark that a service call has been scheduled for a local change. */
|
||||
public schedule(): void {
|
||||
this._scheduled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start over, e.g. when switching entities, so a pending change is not
|
||||
* shown for (or sent to) a different entity. Calls already in flight are
|
||||
* not aborted, but no longer block state updates or trigger `resync`.
|
||||
*/
|
||||
public reset(): void {
|
||||
this._scheduled = false;
|
||||
this._latestCall = undefined;
|
||||
}
|
||||
|
||||
/** Whether an entity state update may be copied into the local value. */
|
||||
public shouldSync(): boolean {
|
||||
return !this.pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the service call for the scheduled change. If the latest call fails
|
||||
* and no new change is scheduled, `resync` is called. Errors are not
|
||||
* rethrown, as `callService` already notifies the user when the call fails.
|
||||
*/
|
||||
public async run(
|
||||
call: () => Promise<unknown>,
|
||||
resync: () => void
|
||||
): Promise<void> {
|
||||
const thisCall = Symbol("call");
|
||||
this._scheduled = false;
|
||||
this._latestCall = thisCall;
|
||||
let failed = false;
|
||||
try {
|
||||
await call();
|
||||
} catch (_err) {
|
||||
failed = true;
|
||||
}
|
||||
if (this._latestCall !== thisCall) {
|
||||
return;
|
||||
}
|
||||
this._latestCall = undefined;
|
||||
if (failed && !this._scheduled) {
|
||||
resync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import memoizeOne from "memoize-one";
|
||||
import { consume } from "../../common/decorators/consume";
|
||||
import { truncateWithEllipsis } from "../../common/string/truncate-with-ellipsis";
|
||||
import type { Condition } from "../../data/automation";
|
||||
import { getRowNote } from "../../data/automation";
|
||||
import type { ConditionDescription } from "../../data/condition";
|
||||
import { internationalizationContext } from "../../data/context";
|
||||
import "../../panels/config/automation/ha-automation-row-options";
|
||||
@@ -42,7 +43,7 @@ export class HaAutomationConditionSummary extends LitElement {
|
||||
this.condition?.condition === "numeric_state";
|
||||
const targetRequired =
|
||||
(descriptionHasTarget || hasEntityTarget) && !this.isNew;
|
||||
const note = this.condition?.note?.trim();
|
||||
const note = getRowNote(this.condition?.note);
|
||||
|
||||
let target: HassServiceTarget | undefined;
|
||||
if (this.condition) {
|
||||
|
||||
@@ -531,6 +531,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
}
|
||||
if (changedProps.has("options")) {
|
||||
chartOptions = { ...chartOptions, ...this._createOptions() };
|
||||
this._disposeSonification();
|
||||
if (this._compareCustomLegendOptions(previousOptions, this.options)) {
|
||||
// custom legend changes may require a resize to layout properly
|
||||
this._shouldResizeChart = true;
|
||||
@@ -1038,6 +1039,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
if (!options) return;
|
||||
const legend = ensureArray(this.options?.legend || [])[0] as
|
||||
LegendComponentOption | undefined;
|
||||
const hiddenCount = this._hiddenDatasets.size;
|
||||
Object.entries(legend?.selected || {}).forEach(([stat, selected]) => {
|
||||
if (selected === false) {
|
||||
this._getAllIdsFromLegend(options, stat).forEach((id) =>
|
||||
@@ -1045,7 +1047,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
);
|
||||
}
|
||||
});
|
||||
this.requestUpdate("_hiddenDatasets");
|
||||
// Flagging this rebuilds and downsamples every series, so only when needed
|
||||
if (this._hiddenDatasets.size !== hiddenCount) {
|
||||
this.requestUpdate("_hiddenDatasets");
|
||||
}
|
||||
}
|
||||
|
||||
private _getDataZoomConfig(): DataZoomComponentOption | undefined {
|
||||
@@ -2089,9 +2094,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
height: 100%;
|
||||
--ha-button-height: 24px;
|
||||
--ha-chip-label-weight: 500;
|
||||
--md-assist-chip-leading-space: var(--ha-space-2);
|
||||
--md-assist-chip-trailing-space: var(--ha-space-2);
|
||||
--md-assist-chip-icon-label-space: var(--ha-space-1);
|
||||
--ha-assist-chip-leading-space: var(--ha-space-2);
|
||||
--ha-assist-chip-trailing-space: var(--ha-space-2);
|
||||
--ha-assist-chip-icon-label-space: var(--ha-space-1);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -10,12 +10,13 @@ import type {
|
||||
} from "echarts/types/dist/shared";
|
||||
import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { listenMediaQuery } from "../../common/dom/media_query";
|
||||
import { consume } from "../../common/decorators/consume";
|
||||
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import { reducedMotionContext } from "../../data/context";
|
||||
import type { HaECOption } from "../../resources/echarts/echarts";
|
||||
import "./ha-chart-base";
|
||||
import type { HaChartBase } from "./ha-chart-base";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
|
||||
export interface NetworkNode {
|
||||
@@ -71,7 +72,7 @@ const PHYSICS_DISABLE_THRESHOLD = 512;
|
||||
let GraphChart: typeof import("echarts/lib/chart/graph/install");
|
||||
|
||||
@customElement("ha-network-graph")
|
||||
export class HaNetworkGraph extends SubscribeMixin(LitElement) {
|
||||
export class HaNetworkGraph extends LitElement {
|
||||
public chart?: EChartsType;
|
||||
|
||||
@property({ attribute: false }) public data!: NetworkData;
|
||||
@@ -90,11 +91,13 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
|
||||
|
||||
@property({ attribute: false }) public searchFilter = "";
|
||||
|
||||
public hass!: HomeAssistant;
|
||||
@state() @consumeLocalize() private _localize!: LocalizeFunc;
|
||||
|
||||
@state() private _highlightedNodes?: Set<string>;
|
||||
|
||||
@state() private _reducedMotion = false;
|
||||
@state()
|
||||
@consume({ context: reducedMotionContext, subscribe: true })
|
||||
private _reducedMotion = false;
|
||||
|
||||
@state() private _physicsEnabled?: boolean;
|
||||
|
||||
@@ -114,16 +117,6 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
protected hassSubscribe() {
|
||||
return [
|
||||
listenMediaQuery("(prefers-reduced-motion)", (matches) => {
|
||||
if (this._reducedMotion !== matches) {
|
||||
this._reducedMotion = matches;
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProperties);
|
||||
if (this._physicsEnabled === undefined && this.data?.nodes?.length > 1) {
|
||||
@@ -164,18 +157,14 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
|
||||
class=${this._physicsEnabled ? "active" : "inactive"}
|
||||
.path=${mdiGoogleCirclesGroup}
|
||||
@click=${this._togglePhysics}
|
||||
label=${this.hass.localize(
|
||||
"ui.panel.config.common.graph.toggle_physics"
|
||||
)}
|
||||
label=${this._localize("ui.panel.config.common.graph.toggle_physics")}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
slot="button"
|
||||
class=${this._showLabels ? "active" : "inactive"}
|
||||
.path=${mdiFormatTextVariant}
|
||||
@click=${this._toggleLabels}
|
||||
label=${this.hass.localize(
|
||||
"ui.panel.config.common.graph.toggle_labels"
|
||||
)}
|
||||
label=${this._localize("ui.panel.config.common.graph.toggle_labels")}
|
||||
></ha-icon-button>
|
||||
</ha-chart-base>`;
|
||||
}
|
||||
|
||||
@@ -376,6 +376,18 @@ export function generateStateHistoryChartLineData(
|
||||
let lastDate: number;
|
||||
let lastNullDate: number | null = null;
|
||||
|
||||
// pushData(timestamp, [value]) for one dataset, minus the per-point array
|
||||
const latestValues: (number | null)[] = [null];
|
||||
const pushValue = (timestamp: number, value: number) => {
|
||||
if (timestamp > endTimeMs) {
|
||||
return;
|
||||
}
|
||||
data[0].data!.push([timestamp, value]);
|
||||
trackY(value);
|
||||
latestValues[0] = value;
|
||||
prevValues = latestValues;
|
||||
};
|
||||
|
||||
// Process chart data.
|
||||
// When state is `unknown`, calculate the value and break the line.
|
||||
const processData = (entityState: LineChartState) => {
|
||||
@@ -393,7 +405,7 @@ export function generateStateHistoryChartLineData(
|
||||
lastValue = value;
|
||||
lastNullDate = null;
|
||||
} else if (value !== null && lastNullDate === null) {
|
||||
pushData(date, [value]);
|
||||
pushValue(date, value);
|
||||
lastDate = date;
|
||||
lastValue = value;
|
||||
} else if (
|
||||
|
||||
@@ -17,8 +17,8 @@ export class HaAssistChip extends HaChipBase {
|
||||
css`
|
||||
:host {
|
||||
--ha-chip-label-color: var(
|
||||
--md-assist-chip-label-text-color,
|
||||
var(--md-sys-color-on-surface, var(--primary-text-color))
|
||||
--ha-assist-chip-label-text-color,
|
||||
var(--primary-text-color)
|
||||
);
|
||||
--ha-button-border-radius: var(
|
||||
--ha-assist-chip-container-shape,
|
||||
@@ -33,11 +33,11 @@ export class HaAssistChip extends HaChipBase {
|
||||
1
|
||||
);
|
||||
--ha-chip-outline-color: var(
|
||||
--md-assist-chip-outline-color,
|
||||
--ha-assist-chip-outline-color,
|
||||
var(--outline-color)
|
||||
);
|
||||
--ha-chip-icon-label-space: var(
|
||||
--md-assist-chip-icon-label-space,
|
||||
--ha-assist-chip-icon-label-space,
|
||||
var(--ha-space-2)
|
||||
);
|
||||
}
|
||||
@@ -58,11 +58,11 @@ export class HaAssistChip extends HaChipBase {
|
||||
}
|
||||
.primary {
|
||||
padding-inline-start: var(
|
||||
--md-assist-chip-leading-space,
|
||||
--ha-assist-chip-leading-space,
|
||||
var(--ha-space-4)
|
||||
);
|
||||
padding-inline-end: var(
|
||||
--md-assist-chip-trailing-space,
|
||||
--ha-assist-chip-trailing-space,
|
||||
var(--ha-space-4)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,11 +64,11 @@ export class HaFilterChip extends HaChipBase {
|
||||
}
|
||||
:host([selected]) {
|
||||
--ha-chip-outline-width: var(
|
||||
--md-filter-chip-selected-outline-width,
|
||||
--ha-filter-chip-selected-outline-width,
|
||||
0px
|
||||
);
|
||||
--ha-chip-container-color: var(
|
||||
--md-filter-chip-selected-container-color,
|
||||
--ha-filter-chip-selected-container-color,
|
||||
rgba(var(--rgb-primary-text-color), 0.15)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,15 +17,15 @@ export class HaInputChip extends HaChipBase {
|
||||
css`
|
||||
:host {
|
||||
--ha-button-border-radius: 16px;
|
||||
--ha-chip-icon-size: var(--md-input-chip-icon-size, 18px);
|
||||
--ha-chip-icon-size: var(--ha-input-chip-icon-size, 18px);
|
||||
}
|
||||
:host([selected]) {
|
||||
--ha-chip-container-color: var(
|
||||
--md-input-chip-selected-container-color,
|
||||
--ha-input-chip-selected-container-color,
|
||||
rgba(var(--rgb-primary-text-color), 0.15)
|
||||
);
|
||||
--ha-chip-outline-width: var(
|
||||
--md-input-chip-selected-outline-width,
|
||||
--ha-input-chip-selected-outline-width,
|
||||
0px
|
||||
);
|
||||
--ha-chip-container-opacity: var(
|
||||
|
||||
@@ -66,15 +66,15 @@ export class DialogDataTableSettings extends LitElement {
|
||||
}
|
||||
|
||||
private _preserveLastFixed(columnOrder) {
|
||||
let strippedColumnOrder;
|
||||
const lastFixedCount = this._lastFixedCount();
|
||||
if (lastFixedCount && columnOrder) {
|
||||
strippedColumnOrder = [...columnOrder];
|
||||
strippedColumnOrder.splice(
|
||||
columnOrder.length - lastFixedCount,
|
||||
lastFixedCount
|
||||
);
|
||||
if (!lastFixedCount || !columnOrder) {
|
||||
return columnOrder;
|
||||
}
|
||||
const strippedColumnOrder = [...columnOrder];
|
||||
strippedColumnOrder.splice(
|
||||
columnOrder.length - lastFixedCount,
|
||||
lastFixedCount
|
||||
);
|
||||
return strippedColumnOrder;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type {
|
||||
LitVirtualizer,
|
||||
VisibilityChangedEvent,
|
||||
} from "@lit-labs/virtualizer";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { mdiArrowDown, mdiArrowUp, mdiChevronUp } from "@mdi/js";
|
||||
import deepClone from "deep-clone-simple";
|
||||
@@ -75,6 +79,8 @@ export interface DataTableSortColumnData {
|
||||
valueColumn?: string;
|
||||
direction?: SortingDirection;
|
||||
groupable?: boolean;
|
||||
// Name in the `sort` and `group` URL params, grouping is only kept with one
|
||||
urlName?: string;
|
||||
moveable?: boolean;
|
||||
hideable?: boolean;
|
||||
defaultHidden?: boolean;
|
||||
@@ -191,7 +197,29 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
@query(".mdc-data-table__header-row") private _headerRow?: HTMLDivElement;
|
||||
|
||||
@query("lit-virtualizer") private _scroller?: HTMLElement;
|
||||
@query("lit-virtualizer")
|
||||
private _scroller?: LitVirtualizer<DataTableRowData>;
|
||||
|
||||
@query(".sticky-group-header") private _stickyGroupHeader?: HTMLElement;
|
||||
|
||||
@query(".sticky-group-header .group-header")
|
||||
private _stickyGroupHeaderCell?: HTMLElement;
|
||||
|
||||
// Group pinned to the top of the scroller while its own header is scrolled out of view
|
||||
@state() private _stickyGroup?: string;
|
||||
|
||||
// Position and width of the sticky group header, applied directly on scroll
|
||||
// to avoid re-rendering the table on every frame
|
||||
private _stickyGroupPosition = { top: 0, width: 0, scrollLeft: 0 };
|
||||
|
||||
private _stickyGroupFrame?: number;
|
||||
|
||||
// Resizing doesn't scroll, but changes the width of the sticky group header
|
||||
private _resizeObserver = new ResizeObserver(() =>
|
||||
this._scheduleStickyGroupUpdate()
|
||||
);
|
||||
|
||||
private _firstVisibleIndex = 0;
|
||||
|
||||
@state() private _collapsedGroups: string[] = [];
|
||||
|
||||
@@ -237,6 +265,7 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._resizeObserver.observe(this);
|
||||
if (this._filteredData?.length) {
|
||||
// Force update of location of rows
|
||||
this._filteredData = [...this._filteredData];
|
||||
@@ -247,7 +276,21 @@ export class HaDataTable extends LitElement {
|
||||
this.updateComplete.then(() => this._calcTableHeight());
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._resizeObserver.disconnect();
|
||||
if (this._stickyGroupFrame !== undefined) {
|
||||
cancelAnimationFrame(this._stickyGroupFrame);
|
||||
this._stickyGroupFrame = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
// The sticky group header may have just been rendered, and the items may
|
||||
// have changed (filter, sort, collapse) without the visible range changing
|
||||
this._positionStickyGroupHeader();
|
||||
this._scheduleStickyGroupUpdate();
|
||||
|
||||
if (!this._headerRow) {
|
||||
return;
|
||||
}
|
||||
@@ -343,6 +386,7 @@ export class HaDataTable extends LitElement {
|
||||
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
|
||||
} else if (properties.has("groupColumn")) {
|
||||
this._collapsedGroups = [];
|
||||
this._stickyGroup = undefined;
|
||||
this._lastSelectedRowId = null;
|
||||
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
|
||||
}
|
||||
@@ -373,6 +417,12 @@ export class HaDataTable extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _memRenderRow = memoizeOne(
|
||||
(columns: DataTableColumnContainer, narrow: boolean) =>
|
||||
(row: DataTableRowData, index: number) =>
|
||||
this._renderRow(columns, narrow, row, index)
|
||||
);
|
||||
|
||||
private _sortedColumns = memoizeOne(
|
||||
(columns: DataTableColumnContainer, columnOrder?: string[]) => {
|
||||
if (!columnOrder || !columnOrder.length) {
|
||||
@@ -408,8 +458,7 @@ export class HaDataTable extends LitElement {
|
||||
protected render() {
|
||||
const columns = this._sortedColumns(this.columns, this.columnOrder);
|
||||
|
||||
const renderRow = (row: DataTableRowData, index: number) =>
|
||||
this._renderRow(columns, this.narrow, row, index);
|
||||
const renderRow = this._memRenderRow(columns, this.narrow);
|
||||
|
||||
const filteredDataLength = this._filteredData?.length || 0;
|
||||
|
||||
@@ -655,9 +704,12 @@ export class HaDataTable extends LitElement {
|
||||
: html`
|
||||
<lit-virtualizer
|
||||
scroller
|
||||
class="mdc-data-table__content scroller ha-scrollbar"
|
||||
class="mdc-data-table__content scroller ha-scrollbar ${classMap(
|
||||
{ grouped: Boolean(this.groupColumn) && !this.autoHeight }
|
||||
)}"
|
||||
tabindex=${ifDefined(!this.autoHeight ? "0" : undefined)}
|
||||
@scroll=${this._saveScrollPos}
|
||||
@visibilityChanged=${this._handleVisibilityChanged}
|
||||
.items=${this._groupData(
|
||||
this._filteredData,
|
||||
this._i18n?.localize,
|
||||
@@ -672,6 +724,25 @@ export class HaDataTable extends LitElement {
|
||||
.keyFunction=${this._keyFunction}
|
||||
.renderItem=${renderRow}
|
||||
></lit-virtualizer>
|
||||
${
|
||||
this.groupColumn &&
|
||||
!this.autoHeight &&
|
||||
this._stickyGroup !== undefined
|
||||
? html`<div
|
||||
class="mdc-data-table__row sticky-group-header"
|
||||
aria-hidden="true"
|
||||
.group=${this._stickyGroup}
|
||||
@click=${this._collapseStickyGroup}
|
||||
>
|
||||
${this._renderGroupHeader(
|
||||
this._stickyGroup,
|
||||
this._collapsedGroups.includes(this._stickyGroup),
|
||||
this._i18n?.localize,
|
||||
true
|
||||
)}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`
|
||||
}
|
||||
</div>
|
||||
@@ -692,7 +763,14 @@ export class HaDataTable extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
if (row.append) {
|
||||
return html`<div class="mdc-data-table__row">${row.content}</div>`;
|
||||
return html`<div
|
||||
class="mdc-data-table__row ${classMap({
|
||||
"group-header-row": row.groupHeader !== undefined,
|
||||
})}"
|
||||
.group=${row.groupHeader}
|
||||
>
|
||||
${row.content}
|
||||
</div>`;
|
||||
}
|
||||
if (row.empty) {
|
||||
return html`<div class="mdc-data-table__row empty-row"></div>`;
|
||||
@@ -800,7 +878,13 @@ export class HaDataTable extends LitElement {
|
||||
key: string,
|
||||
column: DataTableColumnData
|
||||
): boolean {
|
||||
if (column.main || column.showNarrow) {
|
||||
// The grouped value is already shown in the sticky group header, which
|
||||
// isn't available for auto-height tables
|
||||
if (
|
||||
column.main ||
|
||||
column.showNarrow ||
|
||||
(!this.autoHeight && key === this.groupColumn)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return this._isColumnVisible(key, column);
|
||||
@@ -948,33 +1032,15 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
const groupedItems: DataTableRowData[] = [];
|
||||
sortedEntries.forEach(([groupName, rows]) => {
|
||||
const collapsed = collapsedGroups.includes(groupName);
|
||||
groupedItems.push({
|
||||
append: true,
|
||||
selectable: false,
|
||||
content: html`<div
|
||||
class="mdc-data-table__cell group-header"
|
||||
role="cell"
|
||||
.group=${groupName}
|
||||
@click=${this._collapseGroup}
|
||||
>
|
||||
<ha-icon-button
|
||||
.path=${mdiChevronUp}
|
||||
.label=${
|
||||
localize?.(
|
||||
`ui.components.data-table.${collapsed ? "expand" : "collapse"}`
|
||||
) || (collapsed ? "Expand" : "Collapse")
|
||||
}
|
||||
class=${collapsed ? "collapsed" : ""}
|
||||
>
|
||||
</ha-icon-button>
|
||||
${
|
||||
groupName === UNDEFINED_GROUP_KEY
|
||||
? localize?.("ui.components.data-table.ungrouped") ||
|
||||
"Ungrouped"
|
||||
: groupName || ""
|
||||
}
|
||||
</div>`,
|
||||
groupHeader: groupName,
|
||||
content: this._renderGroupHeader(
|
||||
groupName,
|
||||
collapsedGroups.includes(groupName),
|
||||
localize
|
||||
),
|
||||
});
|
||||
if (!collapsedGroups.includes(groupName)) {
|
||||
groupedItems.push(...rows);
|
||||
@@ -995,6 +1061,40 @@ export class HaDataTable extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _renderGroupHeader(
|
||||
groupName: string,
|
||||
collapsed: boolean,
|
||||
localize: LocalizeFunc | undefined,
|
||||
sticky = false
|
||||
) {
|
||||
// The sticky copy is hidden from assistive technology, so its button is
|
||||
// made inert. It handles clicks on its whole row instead, as its cell
|
||||
// shifts with horizontal scrolling.
|
||||
return html`<div
|
||||
class="mdc-data-table__cell group-header"
|
||||
role="cell"
|
||||
.group=${groupName}
|
||||
@click=${sticky ? nothing : this._collapseGroup}
|
||||
>
|
||||
<ha-icon-button
|
||||
.path=${mdiChevronUp}
|
||||
.label=${
|
||||
localize?.(
|
||||
`ui.components.data-table.${collapsed ? "expand" : "collapse"}`
|
||||
) || (collapsed ? "Expand" : "Collapse")
|
||||
}
|
||||
class=${collapsed ? "collapsed" : ""}
|
||||
?inert=${sticky}
|
||||
>
|
||||
</ha-icon-button>
|
||||
${
|
||||
groupName === UNDEFINED_GROUP_KEY
|
||||
? localize?.("ui.components.data-table.ungrouped") || "Ungrouped"
|
||||
: groupName || ""
|
||||
}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _memFilterData = memoizeOne(
|
||||
(
|
||||
data: DataTableRowData[],
|
||||
@@ -1197,6 +1297,101 @@ export class HaDataTable extends LitElement {
|
||||
if (this._headerRow) {
|
||||
this._headerRow.scrollLeft = e.target.scrollLeft;
|
||||
}
|
||||
|
||||
this._scheduleStickyGroupUpdate();
|
||||
}
|
||||
|
||||
private _handleVisibilityChanged(ev: VisibilityChangedEvent) {
|
||||
this._firstVisibleIndex = ev.first;
|
||||
// Fired before the virtualizer positions its children, so measure later
|
||||
this._scheduleStickyGroupUpdate();
|
||||
}
|
||||
|
||||
private _scheduleStickyGroupUpdate() {
|
||||
if (!this.groupColumn) {
|
||||
this._stickyGroup = undefined;
|
||||
return;
|
||||
}
|
||||
if (this._stickyGroupFrame !== undefined) {
|
||||
return;
|
||||
}
|
||||
this._stickyGroupFrame = requestAnimationFrame(() => {
|
||||
this._stickyGroupFrame = undefined;
|
||||
this._updateStickyGroup();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The virtualizer positions rows absolutely, so `position: sticky` can't be
|
||||
* used. Instead, an overlay copy of the current group header is shown at the
|
||||
* top of the scroller, and the next group header slides under it. The
|
||||
* overlay doesn't move with the scroll, as browsers scroll off the main
|
||||
* thread and it would lag behind the rows.
|
||||
*/
|
||||
private _updateStickyGroup() {
|
||||
const scroller = this._scroller;
|
||||
if (
|
||||
!scroller ||
|
||||
!this.groupColumn ||
|
||||
this.autoHeight ||
|
||||
scroller.scrollTop <= 0
|
||||
) {
|
||||
this._stickyGroup = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollerTop = scroller.getBoundingClientRect().top;
|
||||
let group: string | undefined;
|
||||
let currentTop = -Infinity;
|
||||
|
||||
scroller
|
||||
.querySelectorAll<HTMLElement & { group: string }>(".group-header-row")
|
||||
.forEach((row) => {
|
||||
const top = row.getBoundingClientRect().top - scrollerTop;
|
||||
// Rows can sit at fractional positions, so a header less than a
|
||||
// pixel below the top counts as reached
|
||||
if (top < 1 && top > currentTop) {
|
||||
currentTop = top;
|
||||
group = row.group;
|
||||
}
|
||||
});
|
||||
|
||||
if (group === undefined) {
|
||||
// The current group header is not rendered, find it from the first visible row
|
||||
const items = scroller.items;
|
||||
for (
|
||||
let i = Math.min(this._firstVisibleIndex, items.length - 1);
|
||||
i >= 0;
|
||||
i--
|
||||
) {
|
||||
if (items[i]?.append && items[i].groupHeader !== undefined) {
|
||||
group = items[i].groupHeader;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._stickyGroup = group;
|
||||
this._stickyGroupPosition = {
|
||||
top: scroller.offsetTop,
|
||||
width: scroller.clientWidth,
|
||||
scrollLeft: scroller.scrollLeft,
|
||||
};
|
||||
this._positionStickyGroupHeader();
|
||||
}
|
||||
|
||||
private _positionStickyGroupHeader() {
|
||||
const header = this._stickyGroupHeader;
|
||||
if (!header) {
|
||||
return;
|
||||
}
|
||||
const { top, width, scrollLeft } = this._stickyGroupPosition;
|
||||
header.style.width = `${width}px`;
|
||||
header.style.transform = `translateY(${top}px)`;
|
||||
// Follow the horizontal scroll of the group header rows it covers
|
||||
if (this._stickyGroupHeaderCell) {
|
||||
this._stickyGroupHeaderCell.style.transform = `translateX(${-scrollLeft}px)`;
|
||||
}
|
||||
}
|
||||
|
||||
@eventOptions({ passive: true })
|
||||
@@ -1208,6 +1403,40 @@ export class HaDataTable extends LitElement {
|
||||
this._scroller.scrollLeft = e.target.scrollLeft;
|
||||
}
|
||||
|
||||
private _collapseStickyGroup = async (
|
||||
ev: HASSDomCurrentTargetEvent<HTMLElement & { group: string }>
|
||||
) => {
|
||||
const groupName = ev.currentTarget.group;
|
||||
this._collapseGroup(ev);
|
||||
await this.updateComplete;
|
||||
const scroller = this._scroller;
|
||||
if (!scroller) {
|
||||
return;
|
||||
}
|
||||
// Keep the toggled group in view, its rows may have been above the viewport.
|
||||
// Scroll a rendered header to the very top ourselves, as scrollIntoView
|
||||
// would leave room for the sticky header (scroll-padding-top).
|
||||
const row = Array.from(
|
||||
scroller.querySelectorAll<HTMLElement & { group: string }>(
|
||||
".group-header-row"
|
||||
)
|
||||
).find((headerRow) => headerRow.group === groupName);
|
||||
if (row) {
|
||||
scroller.scrollTop +=
|
||||
row.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
||||
return;
|
||||
}
|
||||
const index = scroller.items.findIndex(
|
||||
(item) => item?.append && item.groupHeader === groupName
|
||||
);
|
||||
if (index !== -1) {
|
||||
// The header isn't rendered, but element() still returns a proxy for
|
||||
// any index in the items: its scrollIntoView() pins that index in the
|
||||
// virtualizer's layout, which renders it and aligns it to the top
|
||||
scroller.element(index)?.scrollIntoView({ block: "start" });
|
||||
}
|
||||
};
|
||||
|
||||
private _collapseGroup = (
|
||||
ev: HASSDomCurrentTargetEvent<HTMLElement & { group: string }>
|
||||
) => {
|
||||
@@ -1255,6 +1484,8 @@ export class HaDataTable extends LitElement {
|
||||
:host {
|
||||
height: 100%;
|
||||
--_cell-padding-inline: 16px;
|
||||
--_inset-left: var(--data-table-inset-left, 0px);
|
||||
--_inset-right: var(--data-table-inset-right, 0px);
|
||||
}
|
||||
|
||||
:host([narrow]) {
|
||||
@@ -1290,6 +1521,7 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
.mdc-data-table__row {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
height: var(--data-table-row-height, 52px);
|
||||
width: var(--table-row-width, 100%);
|
||||
}
|
||||
@@ -1327,18 +1559,10 @@ export class HaDataTable extends LitElement {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:host([narrow]) .mdc-data-table {
|
||||
width: calc(
|
||||
100% + var(--safe-area-inset-left, 0px) +
|
||||
var(--safe-area-inset-right, 0px)
|
||||
);
|
||||
margin-left: calc(-1 * var(--safe-area-inset-left, 0px));
|
||||
margin-right: calc(-1 * var(--safe-area-inset-right, 0px));
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
:host([narrow]) .mdc-data-table__header-row {
|
||||
overflow: visible;
|
||||
.mdc-data-table {
|
||||
width: calc(100% + var(--_inset-left) + var(--_inset-right));
|
||||
margin-left: calc(-1 * var(--_inset-left));
|
||||
margin-right: calc(-1 * var(--_inset-right));
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
@@ -1391,6 +1615,8 @@ export class HaDataTable extends LitElement {
|
||||
.load-error {
|
||||
flex-shrink: 0;
|
||||
padding: var(--ha-space-2) var(--ha-space-4);
|
||||
padding-left: calc(var(--ha-space-4) + var(--_inset-left));
|
||||
padding-right: calc(var(--ha-space-4) + var(--_inset-right));
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@@ -1565,6 +1791,28 @@ export class HaDataTable extends LitElement {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.sticky-group-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
z-index: 1;
|
||||
/* Match the top border of the group header rows it covers */
|
||||
border-top: 1px solid transparent;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
background-color: var(--primary-background-color);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.sticky-group-header .group-header {
|
||||
/* Don't paint over the bottom divider */
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Keep focused rows clear of the sticky group header */
|
||||
lit-virtualizer.grouped {
|
||||
scroll-padding-top: var(--data-table-row-height, 52px);
|
||||
}
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
@@ -1646,19 +1894,24 @@ export class HaDataTable extends LitElement {
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.secondary ha-svg-icon {
|
||||
--mdc-icon-size: 16px;
|
||||
vertical-align: text-bottom;
|
||||
margin-inline-end: 2px;
|
||||
}
|
||||
.scroller {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: overlay !important;
|
||||
}
|
||||
|
||||
:host([narrow]) .mdc-data-table__row {
|
||||
box-sizing: border-box;
|
||||
padding-left: var(--safe-area-inset-left, 0px);
|
||||
padding-right: var(--safe-area-inset-right, 0px);
|
||||
.mdc-data-table__row,
|
||||
:host(:not([narrow])) .mdc-data-table__header-row {
|
||||
padding-left: var(--_inset-left);
|
||||
padding-right: var(--_inset-right);
|
||||
}
|
||||
|
||||
:host([narrow]) .mdc-data-table__row:has(.group-header) {
|
||||
.mdc-data-table__row:has(.group-header) {
|
||||
background-color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export class HaDateRangeNav extends HaDateRangePicker {
|
||||
}
|
||||
|
||||
.range {
|
||||
--md-assist-chip-outline-color: transparent;
|
||||
--ha-assist-chip-outline-color: transparent;
|
||||
--ha-assist-chip-container-shape: 0;
|
||||
--ha-assist-chip-container-color: transparent;
|
||||
border-inline: 1px solid var(--divider-color);
|
||||
|
||||
@@ -56,6 +56,7 @@ export abstract class HaDeviceAutomationPicker<
|
||||
private _localizeDeviceAutomation: (
|
||||
localize: LocalizeFunc,
|
||||
states: HassEntities,
|
||||
formatEntityName: HomeAssistant["formatEntityName"],
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
automation: T
|
||||
) => string;
|
||||
@@ -105,7 +106,6 @@ export abstract class HaDeviceAutomationPicker<
|
||||
const value = this._value;
|
||||
|
||||
return html`<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.label=${this.label}
|
||||
.value=${value}
|
||||
.disabled=${!this._automations || this._automations.length === 0}
|
||||
@@ -139,6 +139,7 @@ export abstract class HaDeviceAutomationPicker<
|
||||
const primary = this._localizeDeviceAutomation(
|
||||
this.hass.localize,
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
this._entityReg,
|
||||
automation
|
||||
);
|
||||
@@ -186,6 +187,7 @@ export abstract class HaDeviceAutomationPicker<
|
||||
? this._localizeDeviceAutomation(
|
||||
this.hass.localize,
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
this._entityReg,
|
||||
described
|
||||
)
|
||||
|
||||
@@ -429,7 +429,6 @@ export class HaDevicePicker extends LitElement {
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.noUnknownState=${canReplace}
|
||||
.hass=${this.hass}
|
||||
.autofocus=${this.autofocus}
|
||||
.disabled=${this.disabled}
|
||||
.helper=${this.helper}
|
||||
|
||||
@@ -84,7 +84,6 @@ class HaEntityAttributePicker extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.label=${
|
||||
this.label ??
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import { mdiDragHorizontalVariant, mdiPlus, mdiRestore } from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import {
|
||||
computeDefaultEntityNameItems,
|
||||
ENTITY_NAME_TYPES,
|
||||
type EntityNameItem,
|
||||
type EntityNameType,
|
||||
@@ -22,13 +25,14 @@ import "../ha-button-toggle-group";
|
||||
import "../ha-combo-box-item";
|
||||
import "../ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-input-helper-text";
|
||||
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
|
||||
import "../ha-sortable";
|
||||
import "../input/ha-input";
|
||||
|
||||
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
|
||||
<ha-combo-box-item>
|
||||
<ha-combo-box-item .disabled=${!!item.disabled}>
|
||||
<span slot="headline">${item.primary}</span>
|
||||
${
|
||||
item.secondary
|
||||
@@ -40,6 +44,11 @@ const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
|
||||
|
||||
const KNOWN_TYPES = new Set<string>(ENTITY_NAME_TYPES);
|
||||
|
||||
const DEFAULT_NAME_WITHOUT_ENTITY: EntityNameItem[] = [
|
||||
{ type: "device" },
|
||||
{ type: "entity" },
|
||||
];
|
||||
|
||||
const formatOptionValue = (item: EntityNameItem) => {
|
||||
if (item.type === "text" && item.text) {
|
||||
return item.text;
|
||||
@@ -66,6 +75,9 @@ export class HaEntityNamePicker extends LitElement {
|
||||
@property({ attribute: false }) public value?:
|
||||
string | EntityNameItem | EntityNameItem[];
|
||||
|
||||
@property({ attribute: false }) public defaultValue?:
|
||||
string | EntityNameItem | EntityNameItem[];
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
@@ -83,7 +95,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated) {
|
||||
const items = this._toItems(this.value);
|
||||
const items = this._toItems(this.value ?? this.defaultValue);
|
||||
this._mode =
|
||||
items.length === 1 && items[0].type === "text" ? "custom" : "composed";
|
||||
}
|
||||
@@ -91,7 +103,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
protected willUpdate(_changedProperties: PropertyValues<this>): void {
|
||||
if (this._mode === undefined) {
|
||||
const items = this._toItems(this.value);
|
||||
const items = this._toItems(this.value ?? this.defaultValue);
|
||||
this._mode =
|
||||
items.length === 1 && items[0].type === "text" ? "custom" : "composed";
|
||||
}
|
||||
@@ -147,13 +159,43 @@ export class HaEntityNamePicker extends LitElement {
|
||||
const items = this._items;
|
||||
const value =
|
||||
items.length === 1 && items[0].type === "text" ? items[0].text || "" : "";
|
||||
const stateObj = this.entityId
|
||||
? this.hass.states[this.entityId]
|
||||
: undefined;
|
||||
return html`
|
||||
<ha-input
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.value=${value}
|
||||
.placeholder=${
|
||||
stateObj
|
||||
? this.hass.formatEntityName(
|
||||
stateObj,
|
||||
this.value ?? this.defaultValue
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
@input=${this._textInputChanged}
|
||||
></ha-input>
|
||||
>
|
||||
${this._renderRestoreButton("end")}
|
||||
</ha-input>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderRestoreButton(slot?: string) {
|
||||
if (this.value === undefined || this.disabled) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-icon-button
|
||||
class="restore"
|
||||
slot=${ifDefined(slot)}
|
||||
.path=${mdiRestore}
|
||||
.label=${this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.restore"
|
||||
)}
|
||||
@click=${this._restore}
|
||||
></ha-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -163,7 +205,6 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required && !value.length}
|
||||
.getItems=${this._getFilteredItems}
|
||||
@@ -174,6 +215,9 @@ export class HaEntityNamePicker extends LitElement {
|
||||
.customValueLabel=${this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.custom_name"
|
||||
)}
|
||||
.emptyLabel=${this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.no_more_parts"
|
||||
)}
|
||||
@value-changed=${this._pickerValueChanged}
|
||||
.searchFn=${this._searchFn}
|
||||
.searchLabel=${this.hass.localize(
|
||||
@@ -232,6 +276,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
}
|
||||
</ha-chip-set>
|
||||
</ha-sortable>
|
||||
${this._renderRestoreButton()}
|
||||
</div>
|
||||
</ha-generic-picker>
|
||||
`;
|
||||
@@ -242,6 +287,13 @@ export class HaEntityNamePicker extends LitElement {
|
||||
this._mode = ev.detail.value as "composed" | "custom";
|
||||
}
|
||||
|
||||
private _restore(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
this._mode = undefined;
|
||||
this.value = undefined;
|
||||
fireEvent(this, "value-changed", { value: undefined });
|
||||
}
|
||||
|
||||
private _textInputChanged(ev: Event) {
|
||||
const value = (ev.target as HTMLInputElement).value;
|
||||
const newValue: EntityNameItem[] = value
|
||||
@@ -326,9 +378,41 @@ export class HaEntityNamePicker extends LitElement {
|
||||
}
|
||||
|
||||
private get _items(): EntityNameItem[] {
|
||||
return this._toItems(this.value);
|
||||
if (this.value !== undefined) {
|
||||
return this._toItems(this.value);
|
||||
}
|
||||
if (this.defaultValue !== undefined) {
|
||||
return this._toItems(this.defaultValue);
|
||||
}
|
||||
return this._defaultItems(
|
||||
this.entityId ? this.hass.states[this.entityId] : undefined,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
}
|
||||
|
||||
private _defaultItems = memoizeOne(
|
||||
(
|
||||
stateObj: HassEntity | undefined,
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"]
|
||||
): EntityNameItem[] => {
|
||||
if (!stateObj) {
|
||||
return DEFAULT_NAME_WITHOUT_ENTITY;
|
||||
}
|
||||
return computeDefaultEntityNameItems(stateObj, {
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
private _toItems = memoizeOne((value?: typeof this.value) => {
|
||||
if (typeof value === "string") {
|
||||
if (value === "") {
|
||||
@@ -342,7 +426,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
private _toValue = memoizeOne(
|
||||
(items: EntityNameItem[]): typeof this.value => {
|
||||
if (items.length === 0) {
|
||||
return undefined;
|
||||
return "";
|
||||
}
|
||||
if (items.length === 1) {
|
||||
const item = items[0];
|
||||
@@ -365,18 +449,13 @@ export class HaEntityNamePicker extends LitElement {
|
||||
};
|
||||
|
||||
private _validTypes = memoizeOne((entityId?: string) => {
|
||||
const options = new Set<string>(["text"]);
|
||||
if (!entityId) {
|
||||
return options;
|
||||
}
|
||||
|
||||
const stateObj = this.hass.states[entityId];
|
||||
const stateObj = entityId ? this.hass.states[entityId] : undefined;
|
||||
|
||||
if (!stateObj) {
|
||||
return options;
|
||||
return new Set<string>(["text", ...ENTITY_NAME_TYPES]);
|
||||
}
|
||||
|
||||
options.add("entity");
|
||||
const options = new Set<string>(["text", "entity"]);
|
||||
|
||||
const context = getEntityContext(
|
||||
stateObj,
|
||||
@@ -394,26 +473,23 @@ export class HaEntityNamePicker extends LitElement {
|
||||
});
|
||||
|
||||
private _getItems = memoizeOne((entityId?: string) => {
|
||||
if (!entityId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const types = this._validTypes(entityId);
|
||||
const stateObj = entityId ? this.hass.states[entityId] : undefined;
|
||||
|
||||
const items = ENTITY_NAME_TYPES.filter(
|
||||
(name) => name !== "parent_device" || types.has(name)
|
||||
).map<PickerComboBoxItem>((name) => {
|
||||
const stateObj = this.hass.states[entityId];
|
||||
const isValid = types.has(name);
|
||||
const primary = this.hass.localize(
|
||||
`ui.components.entity.entity-name-picker.types.${name}`
|
||||
);
|
||||
const secondary =
|
||||
(stateObj && isValid
|
||||
? this.hass.formatEntityName(stateObj, { type: name })
|
||||
: this.hass.localize(
|
||||
`ui.components.entity.entity-name-picker.types.${name}_missing` as LocalizeKeys
|
||||
)) || "-";
|
||||
const secondary = stateObj
|
||||
? (isValid
|
||||
? this.hass.formatEntityName(stateObj, { type: name })
|
||||
: this.hass.localize(
|
||||
`ui.components.entity.entity-name-picker.types.${name}_missing` as LocalizeKeys
|
||||
)) || "-"
|
||||
: undefined;
|
||||
|
||||
const id = formatOptionValue({ type: name });
|
||||
|
||||
@@ -421,6 +497,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
id,
|
||||
primary,
|
||||
secondary,
|
||||
disabled: !isValid,
|
||||
search_labels: {
|
||||
primary,
|
||||
secondary: secondary || null,
|
||||
@@ -536,6 +613,8 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
.field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--mdc-text-field-fill-color, whitesmoke);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
border-end-end-radius: var(--ha-border-radius-square);
|
||||
@@ -570,10 +649,24 @@ export class HaEntityNamePicker extends LitElement {
|
||||
background-color: var(--mdc-theme-primary);
|
||||
}
|
||||
|
||||
.field ha-sortable {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
ha-chip-set {
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
|
||||
.restore {
|
||||
--ha-icon-button-size: 36px;
|
||||
--mdc-icon-size: 20px;
|
||||
}
|
||||
|
||||
.field .restore {
|
||||
margin-inline-end: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.add {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,9 @@ export class HaEntityPicker extends LitElement {
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property({ attribute: "aria-label" })
|
||||
public ariaLabel: string | null = null;
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
@@ -449,6 +452,7 @@ export class HaEntityPicker extends LitElement {
|
||||
.allowCustomValue=${this.allowCustomEntity}
|
||||
.required=${this.required}
|
||||
.label=${this.label}
|
||||
.ariaLabel=${this.ariaLabel}
|
||||
.placeholder=${placeholder}
|
||||
.helper=${this.helper}
|
||||
.value=${this.addButton ? undefined : this.value}
|
||||
|
||||
@@ -261,7 +261,6 @@ export class HaStateContentPicker extends LitElement {
|
||||
return html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required && !value.length}
|
||||
.value=${this._getPickerValue()}
|
||||
|
||||
@@ -138,7 +138,6 @@ export class HaEntityStatePicker extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.allowCustomValue=${this.allowCustomValue}
|
||||
.disabled=${this.disabled || noEntity}
|
||||
.autofocus=${this.autofocus}
|
||||
|
||||
@@ -7,11 +7,9 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { consume } from "../../common/decorators/consume";
|
||||
import { STATES_OFF } from "../../common/const";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { apiContext } from "../../data/context";
|
||||
import { apiContext, formattersContext } from "../../data/context";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import { forwardHaptic } from "../../data/haptics";
|
||||
import "../ha-formfield";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-switch";
|
||||
import { getToggleAction } from "../../common/entity/get_toggle_action";
|
||||
@@ -35,6 +33,10 @@ export class HaEntityToggle extends LitElement {
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api?: ContextType<typeof apiContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: formattersContext, subscribe: true })
|
||||
private _formatters?: ContextType<typeof formattersContext>;
|
||||
|
||||
@property({ attribute: false }) public stateObj?: HassEntity;
|
||||
|
||||
@property() public label?: string;
|
||||
@@ -46,13 +48,15 @@ export class HaEntityToggle extends LitElement {
|
||||
return html`<ha-switch disabled></ha-switch> `;
|
||||
}
|
||||
|
||||
const name = this._formatters?.formatEntityName(this.stateObj) ?? "";
|
||||
|
||||
if (
|
||||
this.stateObj.attributes.assumed_state ||
|
||||
this.stateObj.state === UNKNOWN
|
||||
) {
|
||||
return html`
|
||||
<ha-icon-button
|
||||
.label=${`Turn ${computeStateName(this.stateObj)} off`}
|
||||
.label=${`Turn ${name} off`}
|
||||
.path=${mdiFlashOff}
|
||||
.disabled=${this.stateObj.state === UNAVAILABLE}
|
||||
@click=${this._turnOff}
|
||||
@@ -61,7 +65,7 @@ export class HaEntityToggle extends LitElement {
|
||||
}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
.label=${`Turn ${computeStateName(this.stateObj)} on`}
|
||||
.label=${`Turn ${name} on`}
|
||||
.path=${mdiFlash}
|
||||
.disabled=${this.stateObj.state === UNAVAILABLE}
|
||||
@click=${this._turnOn}
|
||||
@@ -70,22 +74,13 @@ export class HaEntityToggle extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
const switchTemplate = html`<ha-switch
|
||||
aria-label=${`Toggle ${computeStateName(this.stateObj)} ${
|
||||
this._isOn ? "off" : "on"
|
||||
}`}
|
||||
return html`<ha-switch
|
||||
aria-label=${`Toggle ${name} ${this._isOn ? "off" : "on"}`}
|
||||
.checked=${this._isOn}
|
||||
.disabled=${this.stateObj.state === UNAVAILABLE}
|
||||
@change=${this._toggleChanged}
|
||||
></ha-switch>`;
|
||||
|
||||
if (!this.label) {
|
||||
return switchTemplate;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-formfield .label=${this.label}>${switchTemplate}</ha-formfield>
|
||||
`;
|
||||
>${this.label}</ha-switch
|
||||
>`;
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
consumeLocalize,
|
||||
} from "../../common/decorators/consume-context-entry";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { unitFromParts, valueFromParts } from "../../common/entity/value_parts";
|
||||
import { FIXED_DOMAIN_STATES } from "../../common/entity/get_states";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
@@ -138,7 +137,7 @@ export class HaStateLabelBadge extends LitElement {
|
||||
)}
|
||||
.description=${
|
||||
this.showName
|
||||
? (this.name ?? computeStateName(entityState))
|
||||
? (this.name ?? this._formatters?.formatEntityName(entityState))
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
|
||||
@@ -296,13 +296,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
hass,
|
||||
stateObj
|
||||
);
|
||||
const searchLabels = computeEntitySearchLabels(
|
||||
stateObj,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const searchLabels = computeEntitySearchLabels(stateObj, hass);
|
||||
|
||||
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
|
||||
output.push({
|
||||
@@ -396,13 +390,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
this.hass,
|
||||
stateObj
|
||||
);
|
||||
const searchLabels = computeEntitySearchLabels(
|
||||
stateObj,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const searchLabels = computeEntitySearchLabels(stateObj, this.hass);
|
||||
|
||||
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
|
||||
return {
|
||||
@@ -523,7 +511,6 @@ export class HaStatisticPicker extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.autofocus=${this.autofocus}
|
||||
.allowCustomValue=${this.allowCustomEntity}
|
||||
.disabled=${this.disabled}
|
||||
|
||||
@@ -230,7 +230,7 @@ export class StateBadge extends LitElement {
|
||||
const domain = computeDomain(this.stateObj.entity_id);
|
||||
if (domain === "update") {
|
||||
cls.set("has-no-radius", true);
|
||||
} else if (domain === "media_player" || domain === "camera") {
|
||||
} else if (domain === "media_player") {
|
||||
cls.set("has-media-image", true);
|
||||
} else if (this.style.backgroundImage !== "") {
|
||||
cls.set("has-image", true);
|
||||
@@ -257,7 +257,10 @@ export class StateBadge extends LitElement {
|
||||
justify-content: center;
|
||||
}
|
||||
:host(.has-image) {
|
||||
border-radius: var(--state-badge-with-image-border-radius, 50%);
|
||||
border-radius: var(
|
||||
--state-badge-with-image-border-radius,
|
||||
var(--ha-picture-border-radius)
|
||||
);
|
||||
}
|
||||
:host(.has-media-image) {
|
||||
border-radius: var(--state-badge-with-media-image-border-radius, 8%);
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../common/config/is_component_loaded";
|
||||
import { consume } from "../common/decorators/consume";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { apiContext, configContext } from "../data/context";
|
||||
import { fetchHassioAddonsInfo } from "../data/hassio/addon";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import type { ValueChangedEvent } from "../types";
|
||||
import "./ha-alert";
|
||||
import "./ha-app-icon";
|
||||
import "./ha-combo-box-item";
|
||||
@@ -42,7 +47,13 @@ const rowRenderer: RenderItemFunction<AddonPickerItem> = (item) => html`
|
||||
|
||||
@customElement("ha-addon-picker")
|
||||
class HaAddonPicker extends LitElement {
|
||||
public hass!: HomeAssistant;
|
||||
@state() @consumeLocalize() private _localize!: LocalizeFunc;
|
||||
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: ContextType<typeof apiContext>;
|
||||
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _config!: ContextType<typeof configContext>;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@@ -73,10 +84,7 @@ class HaAddonPicker extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const label =
|
||||
this.label === undefined && this.hass
|
||||
? this.hass.localize("ui.components.app-picker.app")
|
||||
: this.label;
|
||||
const label = this.label ?? this._localize("ui.components.app-picker.app");
|
||||
|
||||
if (this._error) {
|
||||
return html`<ha-alert alert-type="error">${this._error}</ha-alert>`;
|
||||
@@ -87,7 +95,6 @@ class HaAddonPicker extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.autofocus=${this.autofocus}
|
||||
.label=${label}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
@@ -106,8 +113,8 @@ class HaAddonPicker extends LitElement {
|
||||
|
||||
private async _getApps() {
|
||||
try {
|
||||
if (isComponentLoaded(this.hass.config, "hassio")) {
|
||||
const addonsInfo = await fetchHassioAddonsInfo(this.hass);
|
||||
if (isComponentLoaded(this._config.config, "hassio")) {
|
||||
const addonsInfo = await fetchHassioAddonsInfo(this._api);
|
||||
this._addons = addonsInfo.addons
|
||||
.filter((addon) => addon.version)
|
||||
.map((addon) => ({
|
||||
@@ -123,14 +130,12 @@ class HaAddonPicker extends LitElement {
|
||||
sorting_label: [addon.name, addon.slug].filter(Boolean).join("_"),
|
||||
}));
|
||||
} else {
|
||||
this._error = this.hass.localize(
|
||||
this._error = this._localize(
|
||||
"ui.components.app-picker.error.no_supervisor"
|
||||
);
|
||||
}
|
||||
} catch (_err: any) {
|
||||
this._error = this.hass.localize(
|
||||
"ui.components.app-picker.error.fetch_apps"
|
||||
);
|
||||
this._error = this._localize("ui.components.app-picker.error.fetch_apps");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -372,6 +372,16 @@ export class HaAnsiToHtml extends LitElement {
|
||||
return !!numberOfFoundLines;
|
||||
}
|
||||
|
||||
public trimLines(maxLines: number) {
|
||||
const pre = this._pre;
|
||||
if (!pre) {
|
||||
return;
|
||||
}
|
||||
while (pre.childElementCount > maxLines && pre.firstElementChild) {
|
||||
pre.firstElementChild.remove();
|
||||
}
|
||||
}
|
||||
|
||||
public clear() {
|
||||
if (this._pre) {
|
||||
this._pre.innerHTML = "";
|
||||
|
||||
Loaded 100 of 632 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user