Compare commits

...

126 Commits

Author SHA1 Message Date
Aidan Timson
7ff4993e0b Fix esc closing dialogs with prevent scrim close (#29851) 2026-02-26 13:20:05 +02:00
Norbert Rittel
4e6fbacccc Remove trailing periods from "Learn more" etc. links / tooltips (#29835) 2026-02-26 10:38:54 +00:00
Petar Petrov
2958d49e36 Convert Energy Now tiles to badges (#29845) 2026-02-26 10:38:01 +00:00
Norbert Rittel
92289dc7ea Improve "Create a new … helper" option in entity picker (#29853) 2026-02-26 10:34:42 +00:00
Petar Petrov
f6c1a890e4 Dynamically calculate the date range picker's vertical opening direction (#29850) 2026-02-26 09:33:34 +00:00
Wendelin
d06321ed43 Fix protocols dashboards fab padding (#29847) 2026-02-26 10:31:50 +02:00
dependabot[bot]
3c3d8d9974 Bump rollup from 2.79.2 to 2.80.0 (#29841)
Bumps [rollup](https://github.com/rollup/rollup) from 2.79.2 to 2.80.0.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/v2.80.0/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v2.79.2...v2.80.0)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 2.80.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-26 08:18:15 +02:00
Paul Bottein
4f39fa482d Only ask to refresh dashboard in edit mode or yaml mode (#29826) 2026-02-26 08:16:21 +02:00
renovate[bot]
5d0fe3236c Update dependency @swc/helpers to v0.5.19 (#29836)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-26 07:07:37 +01:00
renovate[bot]
b86142ae50 Update Node.js to v24.14.0 (#29831)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 19:25:42 +00:00
renovate[bot]
5d2f3ee5e8 Update dependency tar to v7.5.9 (#29832)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 19:24:58 +00:00
AlCalzone
e3f7c631a7 Rename "Z-Wave JS" to "Z-Wave" when not referring to the project/org (#29830) 2026-02-25 19:15:16 +00:00
renovate[bot]
49f9d95853 Update dependency vite-tsconfig-paths to v6.1.1 (#29829)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 18:53:12 +01:00
renovate[bot]
db3d7701b5 Update dependency typescript-eslint to v8.56.0 (#29828)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 17:35:36 +00:00
renovate[bot]
3e55acf531 Update dependency @home-assistant/webawesome to v3.2.1-ha.3 (#29810)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 18:26:47 +01:00
renovate[bot]
f102618d9d Update dependency eslint-plugin-wc to v3.1.0 (#29824)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 18:25:06 +01:00
renovate[bot]
a3c02b511d Update dependency jsdom to v28.1.0 (#29825)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 18:24:38 +01:00
Bram Kragten
74111d248e Fix css minifying (#29827) 2026-02-25 17:53:50 +01:00
Bram Kragten
f8161b3505 Merge branch 'rc' into dev 2026-02-25 17:13:44 +01:00
Franck Nijhof
6070c1907a Adjust brands assets to proxy brand images through local API (#29799)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2026-02-25 17:10:38 +01:00
renovate[bot]
ce5991582c Update dependency @html-eslint/eslint-plugin to v0.56.0 (#29818)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 17:37:34 +02:00
Paul Bottein
d17217fc90 Use show in sidebar property instead of checking title (#29815) 2026-02-25 16:37:25 +01:00
renovate[bot]
86b4bd0013 Update dependency eslint-plugin-lit to v2.2.1 (#29821)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 17:36:42 +02:00
renovate[bot]
108ba3abd6 Update dependency eslint-plugin-unused-imports to v4.4.1 (#29822)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 17:36:12 +02:00
Matthias Alphart
d38a2894c4 Remove unused properties in ha-data-table and hass-tabs-subpage-data-table (#29808) 2026-02-25 16:31:39 +01:00
Aidan Timson
4c70376a62 Cleanup old comments (#29823) 2026-02-25 15:24:00 +00:00
Wendelin
8d69bd1401 Fix button active also for icon-buttons (#29820) 2026-02-25 16:21:31 +01:00
renovate[bot]
5dfecd3693 Update dependency @octokit/plugin-retry to v8.1.0 (#29819)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 15:09:19 +00:00
Aidan Timson
efd51d2234 Rename more info "Attributes" to "Details", add raw state and all available attributes (#29811) 2026-02-25 15:57:27 +01:00
renovate[bot]
668299c16a Update dependency marked to v17.0.3 (#29817)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 16:00:51 +02:00
renovate[bot]
5e155a4030 Update dependency glob to v13.0.6 (#29816)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 16:00:25 +02:00
gpoitch
809fa10135 Add day of week to energy chart tooltips (#29803)
* Add day of week to energy chart tooltips

* New localization helpers
2026-02-25 13:31:00 +00:00
Petar Petrov
1cbc38f231 Water flow rate sankey chart in Now view (#29804) 2026-02-25 14:18:48 +01:00
renovate[bot]
9ed39bb523 Update dependency @rspack/core to v1.7.6 (#29812)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 15:10:38 +02:00
renovate[bot]
4e3d66cf40 Update dependency eslint to v9.39.3 (#29813)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 15:10:03 +02:00
renovate[bot]
2eaad79d1c Update dependency @codemirror/view to v6.39.15 (#29807)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 12:47:06 +02:00
renovate[bot]
afef7a2c0f Update dependency @bundle-stats/plugin-webpack-filter to v4.21.10 (#29806)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 12:46:41 +02:00
renovate[bot]
18d5224002 Update dependency @formatjs/intl-datetimeformat to v7.2.2 (#29809)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 12:46:08 +02:00
Johan Henkens
dbffdfeaca Add cover of device type window to the security dashboard (#29797) 2026-02-25 11:11:13 +01:00
Artur Pragacz
0a4b7917ab Update vacuum segment mapping description (#29802) 2026-02-25 08:38:37 +01:00
Simon Lamon
e1524358d9 Remove duplicated buttons (#29798) 2026-02-25 08:22:40 +01:00
Artur Pragacz
8774f9c3fc Add vacuum mapping not configured issue (#29800) 2026-02-25 08:14:38 +01:00
Wendelin
f9a9aeacab Fix app panel narrow header safe area top (#29792)
* Enhance narrow property to reflect changes and adjust header padding for safe area

* Remove safe-area-inset-top for narrow iframe

* handle kiosk mode
2026-02-24 20:26:06 +01:00
ildar170975
b798fee116 Data tables: keep "Actions" as the last column (#29364)
* Data tables: keep "Actions" as the last column

* last_fixed -> lastFixed

* last_fixed -> lastFixed

* last_fixed -> lastFixed

* last_fixed -> lastFixed

* last_fixed -> lastFixed

* last_fixed -> lastFixed

* last_fixed -> lastFixed

* last_fixed -> lastFixed

* last_fixed -> lastFixed

* last_fixed -> lastFixed

* simplify

* Update dialog-data-table-settings.ts

* narrow down a column

* blank line added

* narrow dow Assistants a bit more

* remove moveable/hideable for "actions"

* remove moveable/hideable for "actions"

* remove moveable/hideable for "actions"

* remove moveable/hideable for "actions"

* remove moveable/hideable for "actions"

* remove moveable/hideable for "actions"

* remove moveable/hideable for "actions"

* remove moveable/hideable for "actions"
2026-02-24 20:24:53 +01:00
Norbert Rittel
b25f731f0f Simplify card descriptions using "This …" instead of repeating the name (#29795)
Simplify card description using "This …" instead of repeating the name
2026-02-24 20:17:05 +01:00
Paul Bottein
26a7372c5e Don't show label for toggle all lights and align individual lights (#29794) 2026-02-24 17:28:53 +01:00
Paul Bottein
70d3409d62 Don't use navigation history when using tabs (#29791) 2026-02-24 18:03:48 +02:00
Wendelin
0711ecddab Handle selector edge case for [] (#29790) 2026-02-24 15:30:29 +00:00
Petar Petrov
bcfaa67eba Add power, water and gas current flow rate tile cards (#29788) 2026-02-24 16:01:32 +01:00
Matthias de Baat
1b60e6e04e Reorganize Zigbee settings page (#29671)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2026-02-24 15:11:36 +01:00
Petar Petrov
a1a634f6dc Add footer card support to sections view (#29620)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
Co-authored-by: Wendelin <w@pe8.at>
2026-02-24 11:00:50 +00:00
Petar Petrov
55f48fbb56 Add tabs to energy config page (#29689) 2026-02-24 09:43:02 +00:00
Norbert Rittel
ca4d66b94c Change second tab to "Electricity" in Energy dashboard (#29787) 2026-02-24 10:13:22 +01:00
Aidan Timson
51fd2eedd9 Update gallery with latest adaptive dialog changes (#29672)
* Update gallery with latest adaptive dialog changes

* Update gallery/src/pages/components/ha-adaptive-dialog.ts

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Format

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2026-02-24 06:57:31 +01:00
ildar170975
434a7c2e93 "Numeric state" trigger editor: add a "filter_entity" context to "attribute" selector (#29778)
* add a "filter_entity" context to "attribute" selector

* remove unused variable
2026-02-24 07:55:49 +02:00
Petar Petrov
b849fecf0b Add flow rate picker to gas, water, and water device energy dialogs (#29693)
* Add flow rate picker to gas, water, and water device energy dialogs

Add optional flow rate (stat_rate) picker to gas source, water source,
and water device configuration dialogs, matching the pattern used for
power tracking in grid/solar/battery sources and energy devices.

- Add stat_rate to GasSourceTypeEnergyPreference and WaterSourceTypeEnergyPreference
- Collect gas/water stat_rate in getReferencedStatisticIdsPower()
- Add flow rate ha-statistic-picker filtered to volume_flow_rate device class
- Move entity help text to picker helper props for cleaner layout

* Apply suggestions from code review

Co-authored-by: Norbert Rittel <norbert@rittel.de>

---------

Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-02-24 06:55:35 +01:00
ildar170975
3a48e1996f hui-entities-card: fix "buttons-header-footer" margin-bottom (#29783)
* fix margin-bottom for hui-buttons-header-footer

* typo
2026-02-24 07:54:50 +02:00
ildar170975
8299386737 ha-entity-attribute-picker: add valueRenderer (#29780)
add valueRenderer
2026-02-24 06:52:10 +01:00
Raphael Hehl
5e58ff476f Re-initialize camera stream when backend finishes starting (#29752) 2026-02-23 16:59:53 +01:00
Paul Bottein
758d955053 Add configuration to built-in panels (#29572)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-02-23 16:15:54 +01:00
Kevin Stillhammer
1efd5d26f0 Show allow_negative in DurationSelector options (#29775) 2026-02-23 16:02:33 +01:00
Aidan Timson
36979f10cc Fix types for dialog hide events (#29777) 2026-02-23 15:54:36 +01:00
Aidan Timson
812c59fcb4 Add missing back path for protocol config dashboards (#29770) 2026-02-23 15:36:50 +01:00
karwosts
0c34165bcf Disallow moving a section to non-sections view (#29756) 2026-02-23 10:54:11 +00:00
Matthias de Baat
8c2bfbe9ce Reorganize Matter settings (#29708)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-02-23 10:31:38 +00:00
Bram Kragten
d98e373f64 Bumped version to 20260128.6 2026-02-04 15:41:09 +01:00
Paul Bottein
649516c9fa Change default icon for blank area if not icon configured (#29394) 2026-02-04 15:40:36 +01:00
Paul Bottein
bbc4fb96b2 Load domain translation when integration page load (#29391) 2026-02-04 15:40:35 +01:00
Paul Bottein
0ae639aeb0 Remove old lovelace overview from pickers (#29390) 2026-02-04 15:40:34 +01:00
karwosts
0e7e41065e Don't shrink ha-dropdown checkboxes (#29387) 2026-02-04 15:40:33 +01:00
Paul Bottein
685843f112 Add translations for new overview dialog (#29382) 2026-02-04 15:40:32 +01:00
Paul Bottein
5e1a99d94a Use area icon for area empty state (#29371) 2026-02-04 15:40:31 +01:00
Bram Kragten
d843349865 Bumped version to 20260128.5 2026-02-03 16:58:01 +01:00
Paul Bottein
ec23164aa9 Improve other devices page in home dashboard (#29370) 2026-02-03 16:57:45 +01:00
Paul Bottein
e74ef11101 Hide edit and delete actions for YAML dashboards in config (#29368)
YAML dashboards are defined in configuration files and cannot be
modified or deleted through the UI. This change ensures the edit
and delete actions are only shown for storage-mode dashboards.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 16:57:43 +01:00
Paul Bottein
a222f6a736 Add missing danger variant in dropdown item (#29359) 2026-02-03 16:57:40 +01:00
Petar Petrov
ef3dd16d45 Move dialog scrim to pseudo-element (#29357) 2026-02-03 16:57:39 +01:00
karwosts
5d4e1d205e Fix missing imports in devtools-statistics (#29355) 2026-02-03 16:57:38 +01:00
Darryn Capes-Davis
1ee5ebbe75 Fix CSS minification issue for ha-card (#29354) 2026-02-03 16:57:37 +01:00
Bram Kragten
59d705aa3d Bumped version to 20260128.4 2026-02-02 17:17:24 +01:00
Paul Bottein
332e108dae Fix "Reload resources" menu for YAML resource mode (#29346) 2026-02-02 17:17:17 +01:00
karwosts
3c15b29d0a Entity diagnostic - handle entity not in the registry (#29344) 2026-02-02 17:17:16 +01:00
Wendelin
130c708e23 Fix dropdown width in datatables (#29340) 2026-02-02 17:17:15 +01:00
Paul Bottein
588a14a8a7 Fix type error for missing hass.themes race condition in themes mixin (#29338) 2026-02-02 17:17:14 +01:00
Petar Petrov
a1ef6ad266 Remove redundant dialog backdrop color (#29337) 2026-02-02 17:17:13 +01:00
Aidan Timson
a6c1f87730 Ensure template renderer overflows on overflow (#29335) 2026-02-02 17:17:12 +01:00
Wendelin
49252a3808 Fix missing ha-md-menu in config/labels (#29334) 2026-02-02 17:17:11 +01:00
Aidan Timson
c7877fe38f Show hint only if keyboard shortcuts is enabled (#29332)
Enabled by default, must be explicity disabled
2026-02-02 17:17:10 +01:00
Wendelin
e355a61d8f Revert "Fix automation sidebar ui supported check" (#29331) 2026-02-02 17:17:08 +01:00
Linus Rath
f2e19e51ce Update untracked consumption threshold to 1W (#29310) 2026-02-02 17:17:07 +01:00
karwosts
fd9ab8f561 Use ha-form for condition template (#29301) 2026-02-02 17:17:06 +01:00
Kristel
faa1b3c98f bugfix: add eventlistener for exposed-entities-changed to Entities page (#29299) 2026-02-02 17:17:06 +01:00
Aidan Timson
acc4a84fc9 Fix scrolling for labs page (#29287) 2026-02-02 17:17:05 +01:00
karwosts
4d723dac37 Fix areas cannot be deleted (#29285) 2026-02-02 17:17:03 +01:00
Aidan Timson
f1d4d0ef98 Fix type error for missing hass.config race condition in themes mixin (#29280) 2026-02-02 17:17:02 +01:00
Paul Bottein
88180a2708 Fix demo because of new default panel (#29279) 2026-02-02 17:17:01 +01:00
Aidan Timson
258d87e3d5 Add missing settings nav items for quick search (#29278)
* Add missing repairs quick search item

* Add voice assistants
2026-02-02 17:17:00 +01:00
Wendelin
55f22ba61a Implement fallback for dialog close event in Quick Search (#29260) 2026-02-02 17:16:59 +01:00
Aidan Timson
812f3ca8b9 Change default shortcut tip in Quick Search to mod+k, add tip to settings (#29253) 2026-02-02 17:16:58 +01:00
Marcin Bauer
7f880d11a0 Keep focus on search field when clicking filter chips (#29249)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 17:16:57 +01:00
Bram Kragten
6b2452c538 Update compress.js 2026-02-02 17:15:44 +01:00
Paul Bottein
c2cbf8bd21 Bumped version to 20260128.3 2026-01-30 10:31:26 +01:00
Wendelin
224bcece9c Fix multi select in quick search (#29272)
Add item selection state management to QuickBar component
2026-01-30 10:30:33 +01:00
Wendelin
dc84b7698f Fix --wa-color-text-normal (#29271)
Update normal text color variable in wa.globals.ts
2026-01-30 10:30:32 +01:00
Wendelin
bc22e6a9bd Fix device download diagnostic via overflow (#29269)
fix diagnostic download link handling to simplify URL signing
2026-01-30 10:30:31 +01:00
Simon Lamon
d44874783a Remove duplicated text (#29265) 2026-01-30 10:30:30 +01:00
Paul Bottein
8d1bb5c867 Fix default lovelace yaml loading (#29240) 2026-01-30 10:30:29 +01:00
Bram Kragten
da1b528eee Bumped version to 20260128.2 2026-01-29 18:04:42 +01:00
Paul Bottein
756138408a Remove default title for new dashboards (#29259) 2026-01-29 18:04:09 +01:00
Paul Bottein
3c8f112565 Prevent action in tile container (#29257) 2026-01-29 18:04:08 +01:00
Paul Bottein
2521f3dde4 Fix actions in dashboard overflow menu (#29256) 2026-01-29 18:04:07 +01:00
TheJulianJES
56390aa01a Fix Matter dashboard using disabled and ignored config entries (#29254) 2026-01-29 17:52:32 +01:00
Paul Bottein
9aac5b19da Stop click propagation when clicking item in icon overflow (#29252) 2026-01-29 17:52:31 +01:00
Wendelin
24afc3dc88 Prevent quick search to close from hot keys (#29251) 2026-01-29 17:52:30 +01:00
Paul Bottein
873c7b2947 Remove unused theme option in distribution card (#29250) 2026-01-29 17:52:29 +01:00
Aidan Timson
648db4276b Add protocols to quick search (#29248)
Add protocols to quick search, extract logic and translations
2026-01-29 17:52:28 +01:00
Aidan Timson
f86c3e7856 Remove unused "app" item from quick search (#29244) 2026-01-29 17:52:27 +01:00
Aidan Timson
1d0251cc28 Fixes for picker combo box scrolling and selection (#29242) 2026-01-29 17:52:26 +01:00
Wendelin
518cf87847 Fix quick search apps (#29238) 2026-01-29 17:52:25 +01:00
ildar170975
81a9216c44 computeGroupEntitiesState(): fix condition (#29234)
* fix condition

* fix condition

* prettier
2026-01-29 17:52:24 +01:00
Paul Bottein
f0e10e0058 Fix default yaml lovelace panel loading (#29230) 2026-01-29 17:52:23 +01:00
Paul Bottein
5df8ea4f07 Add welcome banner for new overview dashboard (#29223) 2026-01-29 17:52:22 +01:00
Aidan Timson
73f081f5cc Add meta+click/enter support to quick search (#29220)
* Allow meta+click event from combobox

* Handle new tab events for navigations

* Add mod+enter support for new tab

* Helper function
2026-01-29 17:52:21 +01:00
Petar Petrov
f0d1db1da6 Add non standard power sensor support (#28845)
* Add non standard power sensor support

* remove useless code

* GridPowerSourceInput type for grid power source saving
2026-01-29 17:52:20 +01:00
Bram Kragten
c658eb414b Bumped version to 20260128.1 2026-01-28 17:52:10 +01:00
Bram Kragten
bac493b72b dont include brotli compression 2026-01-28 17:50:19 +01:00
119 changed files with 6146 additions and 2027 deletions

2
.nvmrc
View File

@@ -1 +1 @@
24.13.1
24.14.0

View File

@@ -21,8 +21,8 @@ type DialogType =
| "basic"
| "basic-subtitle-below"
| "basic-subtitle-above"
| "allow-mode-change"
| "form"
| "form-block-mode"
| "actions"
| "large"
| "small";
@@ -69,8 +69,8 @@ export class DemoHaAdaptiveDialog extends LitElement {
<ha-button @click=${this._handleOpenDialog("form")}
>Adaptive dialog with form</ha-button
>
<ha-button @click=${this._handleOpenDialog("form-block-mode")}
>Adaptive dialog with form (block mode change)</ha-button
<ha-button @click=${this._handleOpenDialog("allow-mode-change")}
>Adaptive dialog with allow mode change</ha-button
>
<ha-button @click=${this._handleOpenDialog("actions")}
>Adaptive dialog with actions</ha-button
@@ -164,27 +164,15 @@ export class DemoHaAdaptiveDialog extends LitElement {
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "form-block-mode"}
header-title="Adaptive dialog with form (block mode change)"
header-subtitle="This form will not reset when the viewport size changes"
block-mode-change
.allowModeChange=${this._openDialog === "allow-mode-change"}
header-title="Adaptive dialog with allow mode change"
header-subtitle="Resize the window while this dialog is open"
@closed=${this._handleClosed}
>
<ha-form autofocus .schema=${SCHEMA}></ha-form>
<ha-dialog-footer slot="footer">
<ha-button
@click=${this._handleClosed}
slot="secondaryAction"
variant="plain"
>Cancel</ha-button
>
<ha-button
@click=${this._handleClosed}
slot="primaryAction"
variant="accent"
>Submit</ha-button
>
</ha-dialog-footer>
<div>
This dialog can switch between dialog mode and bottom sheet mode
while open.
</div>
</ha-adaptive-dialog>
<ha-adaptive-dialog
@@ -225,10 +213,9 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ul>
<p>
The mode is determined automatically and updates when the window is
resized. To prevent mode changes after the initial mount (useful for
preventing form resets), use the <code>block-mode-change</code>
attribute.
By default, the mode is determined at mount time and then stays fixed
while the dialog is open. To allow switching modes while the viewport
changes, use the <code>allow-mode-change</code> attribute.
</p>
<h3>Width</h3>
@@ -399,10 +386,10 @@ export class DemoHaAdaptiveDialog extends LitElement {
</p>
<p>
Use the <code>block-mode-change</code> attribute when you want to
prevent the dialog from switching modes after it's opened. This is
especially useful for forms, as it prevents form data from being lost
when users resize their browser window.
Use the <code>allow-mode-change</code> attribute when you want the
dialog to switch between modes as the viewport changes after opening.
For forms, you can keep the default behavior to avoid resetting fields
on resize.
</p>
<h3>Example usage</h3>
@@ -426,23 +413,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
&lt;/ha-dialog-footer&gt;
&lt;/ha-adaptive-dialog&gt;</code></pre>
<p>Example with <code>block-mode-change</code> for forms:</p>
<pre><code>&lt;ha-adaptive-dialog
.hass=\${this.hass}
open
header-title="Edit configuration"
block-mode-change
&gt;
&lt;ha-form .schema=\${schema} .data=\${data}&gt;&lt;/ha-form&gt;
&lt;ha-dialog-footer slot="footer"&gt;
&lt;ha-button slot="secondaryAction" variant="plain"
&gt;Cancel&lt;/ha-button
&gt;
&lt;ha-button slot="primaryAction" variant="accent"&gt;Save&lt;/ha-button&gt;
&lt;/ha-dialog-footer&gt;
&lt;/ha-adaptive-dialog&gt;</code></pre>
<h3>API</h3>
<p>
@@ -520,12 +490,10 @@ export class DemoHaAdaptiveDialog extends LitElement {
<td></td>
</tr>
<tr>
<td><code>block-mode-change</code></td>
<td><code>allow-mode-change</code></td>
<td>
When set, the mode is determined at mount time based on the
current screen size, but subsequent mode changes are blocked.
Useful for preventing forms from resetting when the viewport
size changes.
When set, the dialog can switch between modes as the viewport
size changes while it is open.
</td>
<td><code>false</code></td>
<td><code>false</code>, <code>true</code></td>

View File

@@ -34,10 +34,10 @@
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.6.0",
"@codemirror/state": "6.5.4",
"@codemirror/view": "6.39.12",
"@codemirror/view": "6.39.15",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.2.1",
"@formatjs/intl-datetimeformat": "7.2.2",
"@formatjs/intl-displaynames": "7.2.1",
"@formatjs/intl-durationformat": "0.10.1",
"@formatjs/intl-getcanonicallocales": "3.2.1",
@@ -52,7 +52,7 @@
"@fullcalendar/list": "6.1.20",
"@fullcalendar/luxon3": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@home-assistant/webawesome": "3.2.1-ha.2",
"@home-assistant/webawesome": "3.2.1-ha.3",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
@@ -83,7 +83,7 @@
"@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.18",
"@swc/helpers": "0.5.19",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0",
@@ -118,7 +118,7 @@
"lit": "3.3.2",
"lit-html": "3.3.2",
"luxon": "3.7.2",
"marked": "17.0.1",
"marked": "17.0.3",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -148,14 +148,14 @@
"@babel/helper-define-polyfill-provider": "0.6.6",
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.0",
"@bundle-stats/plugin-webpack-filter": "4.21.9",
"@html-eslint/eslint-plugin": "0.55.0",
"@bundle-stats/plugin-webpack-filter": "4.21.10",
"@html-eslint/eslint-plugin": "0.56.0",
"@lokalise/node-api": "15.6.1",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.2",
"@rspack/core": "1.7.5",
"@rspack/core": "1.7.6",
"@rspack/dev-server": "1.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.25",
@@ -172,7 +172,7 @@
"@types/mocha": "10.0.10",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "6.1.13",
"@types/tar": "7.0.87",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.0.18",
@@ -180,25 +180,25 @@
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.1",
"eslint": "9.39.2",
"eslint": "9.39.3",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.10",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-lit": "2.1.1",
"eslint-plugin-lit": "2.2.1",
"eslint-plugin-lit-a11y": "5.1.1",
"eslint-plugin-unused-imports": "4.3.0",
"eslint-plugin-wc": "3.0.2",
"eslint-plugin-unused-imports": "4.4.1",
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
"fs-extra": "11.3.3",
"glob": "13.0.1",
"glob": "13.0.6",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "28.0.0",
"jsdom": "28.1.0",
"jszip": "3.10.1",
"lint-staged": "16.2.7",
"lit-analyzer": "2.0.3",
@@ -210,12 +210,12 @@
"rspack-manifest-plugin": "5.2.1",
"serve": "14.2.5",
"sinon": "21.0.1",
"tar": "7.5.8",
"tar": "7.5.9",
"terser-webpack-plugin": "5.3.16",
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.54.0",
"vite-tsconfig-paths": "6.0.5",
"typescript-eslint": "8.56.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.0.18",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
@@ -235,6 +235,6 @@
},
"packageManager": "yarn@4.12.0",
"volta": {
"node": "24.13.1"
"node": "24.14.0"
}
}

View File

@@ -210,3 +210,39 @@ const formatDateWeekdayShortMem = memoizeOne(
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
})
);
// Mon, Aug 10
export const formatDateWeekdayVeryShortDate = (
dateObj: Date,
locale: FrontendLocaleData,
config: HassConfig
) =>
formatDateWeekdayVeryShortDateMem(locale, config.time_zone).format(dateObj);
const formatDateWeekdayVeryShortDateMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language, {
weekday: "short",
month: "short",
day: "numeric",
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
})
);
// Mon, Aug 10, 2021
export const formatDateWeekdayShortDate = (
dateObj: Date,
locale: FrontendLocaleData,
config: HassConfig
) => formatDateWeekdayShortDateMem(locale, config.time_zone).format(dateObj);
const formatDateWeekdayShortDateMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language, {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
})
);

View File

@@ -32,11 +32,13 @@ export class DialogDataTableSettings extends LitElement {
@state() private _hiddenColumns?: string[];
private _lastFixedKeys: string[] = [];
@state() private _open = false;
public showDialog(params: DataTableSettingsDialogParams) {
this._params = params;
this._columnOrder = params.columnOrder;
this._columnOrder = this._preserveLastFixed(params.columnOrder);
this._hiddenColumns = params.hiddenColumns;
this._open = true;
}
@@ -50,6 +52,29 @@ export class DialogDataTableSettings extends LitElement {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _lastFixedCount(): number {
const lastFixedKeys = Object.keys(this._params!.columns).filter(
(col) => this._params!.columns[col].lastFixed
);
if (lastFixedKeys.length) {
this._lastFixedKeys = lastFixedKeys;
}
return lastFixedKeys.length;
}
private _preserveLastFixed(columnOrder) {
let strippedColumnOrder;
const lastFixedCount = this._lastFixedCount();
if (lastFixedCount && columnOrder) {
strippedColumnOrder = [...columnOrder];
strippedColumnOrder.splice(
columnOrder.length - lastFixedCount,
lastFixedCount
);
}
return strippedColumnOrder;
}
private _sortedColumns = memoizeOne(
(
columns: DataTableColumnContainer,
@@ -57,7 +82,7 @@ export class DialogDataTableSettings extends LitElement {
hiddenColumns: string[] | undefined
) =>
Object.keys(columns)
.filter((col) => !columns[col].hidden)
.filter((col) => !columns[col].hidden && !columns[col].lastFixed)
.sort((a, b) => {
const orderA = columnOrder?.indexOf(a) ?? -1;
const orderB = columnOrder?.indexOf(b) ?? -1;
@@ -195,7 +220,8 @@ export class DialogDataTableSettings extends LitElement {
this._columnOrder = columnOrder;
this._params!.onUpdate(this._columnOrder, this._hiddenColumns);
const reportedOrder = columnOrder.concat(this._lastFixedKeys);
this._params!.onUpdate(reportedOrder, this._hiddenColumns);
}
private _toggle(ev) {
@@ -276,7 +302,8 @@ export class DialogDataTableSettings extends LitElement {
this._hiddenColumns = hidden;
this._params!.onUpdate(this._columnOrder, this._hiddenColumns);
const reportedOrder = this._columnOrder.concat(this._lastFixedKeys);
this._params!.onUpdate(reportedOrder, this._hiddenColumns);
}
private _reset() {

View File

@@ -86,6 +86,7 @@ export interface DataTableColumnData<T = any> extends DataTableSortColumnData {
flex?: number;
forceLTR?: boolean;
hidden?: boolean;
lastFixed?: boolean;
}
export type ClonedDataTableColumnData = Omit<DataTableColumnData, "title"> & {
@@ -135,9 +136,6 @@ export class HaDataTable extends LitElement {
@property({ attribute: false }) public searchLabel?: string;
@property({ type: Boolean, attribute: "no-label-float" })
public noLabelFloat? = false;
@property({ type: String }) public filter = "";
@property({ attribute: false }) public groupColumn?: string;
@@ -359,6 +357,11 @@ export class HaDataTable extends LitElement {
.sort((a, b) => {
const orderA = columnOrder!.indexOf(a);
const orderB = columnOrder!.indexOf(b);
const fixedA = Boolean(columns[a].lastFixed);
const fixedB = Boolean(columns[b].lastFixed);
if (fixedA !== fixedB) {
return fixedA ? 1 : -1;
}
if (orderA !== orderB) {
if (orderA === -1) {
return 1;
@@ -394,7 +397,6 @@ export class HaDataTable extends LitElement {
.hass=${this.hass}
@value-changed=${this._handleSearchChange}
.label=${this.searchLabel}
.noLabelFloat=${this.noLabelFloat}
></search-input>
</div>
`
@@ -428,9 +430,9 @@ export class HaDataTable extends LitElement {
<ha-checkbox
class="mdc-data-table__row-checkbox"
@change=${this._handleHeaderRowCheckboxClick}
.indeterminate=${this._checkedRows.length &&
.indeterminate=${!!this._checkedRows.length &&
this._checkedRows.length !== this._checkableRowsCount}
.checked=${this._checkedRows.length &&
.checked=${!!this._checkedRows.length &&
this._checkedRows.length === this._checkableRowsCount}
>
</ha-checkbox>

View File

@@ -6,6 +6,7 @@ import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-generic-picker";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
@customElement("ha-entity-attribute-picker")
class HaEntityAttributePicker extends LitElement {
@@ -94,12 +95,19 @@ class HaEntityAttributePicker extends LitElement {
.helper=${this.helper}
.allowCustomValue=${this.allowCustomValue}
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
`;
}
private _valueRenderer: PickerValueRenderer = (value: string) => {
const items = this._getItems();
const item = items.find((option) => option.id === value);
return html`<span slot="headline">${item?.primary ?? value}</span>`;
};
private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const newValue = ev.detail.value;

View File

@@ -15,6 +15,7 @@ import { iconColorCSS } from "../../common/style/icon_color_css";
import { cameraUrlWithWidthHeight } from "../../data/camera";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import type { HomeAssistant } from "../../types";
import { addBrandsAuth } from "../../util/brands-url";
import "../ha-state-icon";
@customElement("state-badge")
@@ -137,6 +138,7 @@ export class StateBadge extends LitElement {
let imageUrl =
stateObj.attributes.entity_picture_local ||
stateObj.attributes.entity_picture;
imageUrl = addBrandsAuth(imageUrl);
if (this.hass) {
imageUrl = this.hass.hassUrl(imageUrl);
}

View File

@@ -672,11 +672,11 @@ export class HaAssistChat extends LitElement {
--markdown-code-background-color: var(--primary-background-color);
--markdown-code-text-color: var(--primary-text-color);
--markdown-list-indent: 1.15em;
&:not(:has(ha-markdown-element)) {
min-height: 1lh;
min-width: 1lh;
flex-shrink: 0;
}
}
ha-markdown:not(:has(ha-markdown-element)) {
min-height: 1lh;
min-width: 1lh;
flex-shrink: 0;
}
.bouncer {
width: 48px;

View File

@@ -141,6 +141,9 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
private _handleKeyDown = (ev: KeyboardEvent) => {
if (ev.key === "Escape") {
this._escapePressed = true;
if (this.preventScrimClose) {
ev.preventDefault();
}
ev.stopPropagation();
(ev.currentTarget as WaDrawer).open = false;
}

View File

@@ -84,6 +84,9 @@ export class HaButton extends Button {
--button-color-fill-loud-hover: var(
--ha-color-fill-primary-loud-hover
);
--button-color-fill-quiet-active: var(
--ha-color-fill-primary-quiet-active
);
}
:host([variant="neutral"]) {
@@ -99,6 +102,9 @@ export class HaButton extends Button {
--button-color-fill-loud-hover: var(
--ha-color-fill-neutral-loud-hover
);
--button-color-fill-quiet-active: var(
--ha-color-fill-neutral-normal-active
);
}
:host([variant="success"]) {
@@ -114,6 +120,9 @@ export class HaButton extends Button {
--button-color-fill-loud-hover: var(
--ha-color-fill-success-loud-hover
);
--button-color-fill-quiet-active: var(
--ha-color-fill-success-quiet-active
);
}
:host([variant="warning"]) {
@@ -129,6 +138,9 @@ export class HaButton extends Button {
--button-color-fill-loud-hover: var(
--ha-color-fill-warning-loud-hover
);
--button-color-fill-quiet-active: var(
--ha-color-fill-warning-quiet-active
);
}
:host([variant="danger"]) {
@@ -144,6 +156,9 @@ export class HaButton extends Button {
--button-color-fill-loud-hover: var(
--ha-color-fill-danger-loud-hover
);
--button-color-fill-quiet-active: var(
--ha-color-fill-danger-quiet-active
);
}
:host([appearance~="plain"]) .button {
@@ -187,6 +202,10 @@ export class HaButton extends Button {
background-color: var(--ha-color-fill-disabled-normal-resting);
color: var(--ha-color-on-disabled-normal);
}
:host([appearance~="plain"])
.button:not(.disabled):not(.loading):active {
background-color: var(--button-color-fill-quiet-active);
}
:host([appearance~="accent"]) .button {
background-color: var(

View File

@@ -2,6 +2,7 @@ import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import { STATE_RUNNING } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one";
import { computeStateName } from "../common/entity/compute_state_name";
import { supportsFeature } from "../common/entity/supports-feature";
@@ -58,12 +59,22 @@ export class HaCameraStream extends LitElement {
@state() private _webRtcStreams?: { hasAudio: boolean; hasVideo: boolean };
public willUpdate(changedProps: PropertyValues): void {
if (
const entityChanged =
changedProps.has("stateObj") &&
this.stateObj &&
(changedProps.get("stateObj") as CameraEntity | undefined)?.entity_id !==
this.stateObj.entity_id
) {
this.stateObj.entity_id;
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
const backendStarted =
changedProps.has("hass") &&
this.hass &&
this.stateObj &&
oldHass &&
this.hass.config.state === STATE_RUNNING &&
oldHass.config?.state !== STATE_RUNNING;
if (entityChanged || backendStarted) {
this._getCapabilities();
this._getPosterUrl();
}

View File

@@ -93,6 +93,8 @@ export class HaDateRangePicker extends LitElement {
| "center"
| "inline";
@state() private _calcedVerticalOpeningDirection?: "up" | "down";
protected willUpdate(changedProps: PropertyValues) {
if (
(!this.hasUpdated && this.ranges === undefined) ||
@@ -134,7 +136,9 @@ export class HaDateRangePicker extends LitElement {
opening-direction=${ifDefined(
this.openingDirection || this._calcedOpeningDirection
)}
opens-vertical=${ifDefined(this.verticalOpeningDirection)}
opens-vertical=${ifDefined(
this.verticalOpeningDirection || this._calcedVerticalOpeningDirection
)}
first-day=${firstWeekdayIndex(this.hass.locale)}
language=${this.hass.locale.language}
@change=${this._handleChange}
@@ -328,17 +332,24 @@ export class HaDateRangePicker extends LitElement {
private _handleClick() {
// calculate opening direction if not set
if (!this._dateRangePicker.open && !this.openingDirection) {
const datePickerPosition = this.getBoundingClientRect().x;
let opens: "right" | "left" | "center" | "inline";
if (datePickerPosition > (2 * window.innerWidth) / 3) {
opens = "left";
} else if (datePickerPosition < window.innerWidth / 3) {
opens = "right";
} else {
opens = "center";
if (!this._dateRangePicker.open) {
if (!this.openingDirection) {
const datePickerPosition = this.getBoundingClientRect().x;
let opens: "right" | "left" | "center" | "inline";
if (datePickerPosition > (2 * window.innerWidth) / 3) {
opens = "left";
} else if (datePickerPosition < window.innerWidth / 3) {
opens = "right";
} else {
opens = "center";
}
this._calcedOpeningDirection = opens;
}
if (!this.verticalOpeningDirection) {
const rect = this.getBoundingClientRect();
this._calcedVerticalOpeningDirection =
rect.top > window.innerHeight / 2 ? "up" : "down";
}
this._calcedOpeningDirection = opens;
}
}

View File

@@ -20,6 +20,8 @@ import "./ha-icon-button";
export type DialogWidth = "small" | "medium" | "large" | "full";
type DialogHideEvent = CustomEvent<{ source?: Element }>;
/**
* Home Assistant dialog component
*
@@ -217,7 +219,7 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
fireEvent(this, "after-show");
};
private _handleAfterHide = (ev: CustomEvent<{ source: Element }>) => {
private _handleAfterHide = (ev: DialogHideEvent) => {
if (ev.eventPhase === Event.AT_TARGET) {
this._open = false;
fireEvent(this, "closed");
@@ -237,13 +239,16 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
private _handleKeyDown(ev: KeyboardEvent) {
if (ev.key === "Escape") {
this._escapePressed = true;
if (this.preventScrimClose) {
ev.preventDefault();
}
ev.stopPropagation();
(ev.currentTarget as WaDialog).open = false;
}
}
private _handleHide(ev: CustomEvent<{ source: Element }>) {
const sourceIsDialog = ev.detail.source === (ev.target as WaDialog).dialog;
private _handleHide(ev: DialogHideEvent) {
const sourceIsDialog = ev.detail?.source === (ev.target as WaDialog).dialog;
if (this.preventScrimClose && this._escapePressed && sourceIsDialog) {
ev.preventDefault();
@@ -332,29 +337,29 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
@media all and (max-width: 450px), all and (max-height: 500px) {
:host([type="standard"]) {
--ha-dialog-border-radius: 0;
}
wa-dialog {
/* Make the container fill the whole screen width and not the safe width */
--full-width: var(--ha-dialog-width-full, 100vw);
--width: var(--full-width);
}
:host([type="standard"]) wa-dialog {
/* Make the container fill the whole screen width and not the safe width */
--full-width: var(--ha-dialog-width-full, 100vw);
--width: var(--full-width);
}
wa-dialog::part(dialog) {
/* Make the dialog fill the whole screen height and not the safe height */
min-height: var(--ha-dialog-min-height, 100vh);
min-height: var(--ha-dialog-min-height, 100dvh);
max-height: var(--ha-dialog-max-height, 100vh);
max-height: var(--ha-dialog-max-height, 100dvh);
margin-top: 0;
margin-bottom: 0;
/* Use safe area as padding instead of the container size */
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
/* Reset the transform to center the dialog */
transform: none;
}
:host([type="standard"]) wa-dialog::part(dialog) {
/* Make the dialog fill the whole screen height and not the safe height */
min-height: var(--ha-dialog-min-height, 100vh);
min-height: var(--ha-dialog-min-height, 100dvh);
max-height: var(--ha-dialog-max-height, 100vh);
max-height: var(--ha-dialog-max-height, 100dvh);
margin-top: 0;
margin-bottom: 0;
/* Use safe area as padding instead of the container size */
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
/* Reset the transform to center the dialog */
transform: none;
}
}

View File

@@ -84,13 +84,11 @@ export class HaMarkdown extends LitElement {
ha-markdown-element > :is(ol, ul) {
padding-inline-start: var(--markdown-list-indent, revert);
}
li {
&:has(input[type="checkbox"]) {
list-style: none;
& > input[type="checkbox"] {
margin-left: 0;
}
}
li:has(input[type="checkbox"]) {
list-style: none;
}
li:has(input[type="checkbox"]) > input[type="checkbox"] {
margin-left: 0;
}
svg {
background-color: var(--markdown-svg-background-color, none);
@@ -137,10 +135,10 @@ export class HaMarkdown extends LitElement {
--markdown-table-border-width: 0;
--markdown-table-padding-inline: 0;
--markdown-table-padding-block: 0;
th,
td {
vertical-align: middle;
}
}
table[role="presentation"] th,
table[role="presentation"] td {
vertical-align: middle;
}
table[role="presentation"] td[valign="top"],
table[role="presentation"] th[valign="top"] {

View File

@@ -64,7 +64,7 @@ export class HaEntitySelector extends LitElement {
if (!this.selector.entity?.multiple) {
return html`<ha-entity-picker
.hass=${this.hass}
.value=${this.value}
.value=${typeof this.value === "string" ? this.value : ""}
.label=${this.label}
.placeholder=${this.placeholder}
.helper=${this.helper}

View File

@@ -13,7 +13,11 @@ import {
} from "../../data/media-player";
import type { MediaSelector, MediaSelectorValue } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import { brandsUrl, extractDomainFromBrandUrl } from "../../util/brands-url";
import {
brandsUrl,
extractDomainFromBrandUrl,
isBrandUrl,
} from "../../util/brands-url";
import "../ha-alert";
import "../ha-form/ha-form";
import type { SchemaUnion } from "../ha-form/types";
@@ -72,16 +76,7 @@ export class HaMediaSelector extends LitElement {
if (thumbnail === oldThumbnail) {
return;
}
if (thumbnail && thumbnail.startsWith("/")) {
this._thumbnailUrl = undefined;
// Thumbnails served by local API require authentication
getSignedPath(this.hass, thumbnail).then((signedPath) => {
this._thumbnailUrl = signedPath.path;
});
} else if (
thumbnail &&
thumbnail.startsWith("https://brands.home-assistant.io")
) {
if (thumbnail && isBrandUrl(thumbnail)) {
// The backend is not aware of the theme used by the users,
// so we rewrite the URL to show a proper icon
this._thumbnailUrl = brandsUrl({
@@ -89,6 +84,12 @@ export class HaMediaSelector extends LitElement {
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
});
} else if (thumbnail && thumbnail.startsWith("/")) {
this._thumbnailUrl = undefined;
// Thumbnails served by local API require authentication
getSignedPath(this.hass, thumbnail).then((signedPath) => {
this._thumbnailUrl = signedPath.path;
});
} else {
this._thumbnailUrl = thumbnail;
}

View File

@@ -221,7 +221,7 @@ export class HaSelectSelector extends LitElement {
.disabled=${this.disabled}
.required=${this.required}
.getItems=${this._getItems(options)}
.value=${this.value as string | undefined}
.value=${typeof this.value === "string" ? this.value : undefined}
@value-changed=${this._comboBoxValueChanged}
allow-custom-value
></ha-generic-picker>
@@ -231,7 +231,7 @@ export class HaSelectSelector extends LitElement {
return html`
<ha-select
.label=${this.label ?? ""}
.value=${(this.value as string) ?? ""}
.value=${typeof this.value === "string" ? this.value : ""}
.helper=${this.helper ?? ""}
.disabled=${this.disabled}
.required=${this.required}

View File

@@ -69,6 +69,10 @@ const SELECTOR_SCHEMAS = {
default: true,
selector: { boolean: {} },
},
{
name: "allow_negative",
selector: { boolean: {} },
},
] as const,
entity: [
{

View File

@@ -144,6 +144,7 @@ export const computePanels = memoizeOne(
if (
!isDefaultPanel &&
(!panel.title ||
panel.show_in_sidebar === false ||
hiddenPanels.includes(panel.url_path) ||
(panel.default_visible === false &&
!panelsOrder.includes(panel.url_path)))

View File

@@ -765,6 +765,16 @@ export class HaMediaPlayerBrowse extends LitElement {
return "";
}
if (isBrandUrl(thumbnailUrl)) {
// The backend is not aware of the theme used by the users,
// so we rewrite the URL to show a proper icon
return brandsUrl({
domain: extractDomainFromBrandUrl(thumbnailUrl),
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
});
}
if (thumbnailUrl.startsWith("/")) {
// Thumbnails served by local API require authentication
return new Promise((resolve, reject) => {
@@ -787,16 +797,6 @@ export class HaMediaPlayerBrowse extends LitElement {
});
}
if (isBrandUrl(thumbnailUrl)) {
// The backend is not aware of the theme used by the users,
// so we rewrite the URL to show a proper icon
thumbnailUrl = brandsUrl({
domain: extractDomainFromBrandUrl(thumbnailUrl),
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
});
}
return thumbnailUrl;
}

View File

@@ -159,6 +159,9 @@ export interface GasSourceTypeEnergyPreference {
// kWh/volume meter
stat_energy_from: string;
// Flow rate (m³/h, L/min, etc.)
stat_rate?: string;
// $ meter
stat_cost: string | null;
@@ -174,6 +177,9 @@ export interface WaterSourceTypeEnergyPreference {
// volume meter
stat_energy_from: string;
// Flow rate (L/min, gal/min, m³/h, etc.)
stat_rate?: string;
// $ meter
stat_cost: string | null;
@@ -368,6 +374,9 @@ export const getReferencedStatisticIdsPower = (
for (const source of prefs.energy_sources) {
if (source.type === "gas" || source.type === "water") {
if (source.stat_rate) {
statIDs.push(source.stat_rate);
}
continue;
}
@@ -389,6 +398,7 @@ export const getReferencedStatisticIdsPower = (
}
}
statIDs.push(...prefs.device_consumption.map((d) => d.stat_rate));
statIDs.push(...prefs.device_consumption_water.map((d) => d.stat_rate));
return statIDs.filter(Boolean) as string[];
};
@@ -1391,6 +1401,80 @@ export const calculateSolarConsumedGauge = (
return undefined;
};
/**
* Conversion factors from each flow rate unit to L/min.
* All HA-supported UnitOfVolumeFlowRate values are covered.
*
* m³/h → 1000/60 = 16.6667 L/min
* m³/min → 1000 L/min
* m³/s → 60000 L/min
* ft³/min→ 28.3168 L/min
* L/h → 1/60 L/min
* L/min → 1 L/min
* L/s → 60 L/min
* gal/h → 3.78541/60 L/min
* gal/min→ 3.78541 L/min
* gal/d → 3.78541/1440 L/min
* mL/s → 0.06 L/min
*/
/** Exact number of liters in one US gallon */
const LITERS_PER_GALLON = 3.785411784;
const FLOW_RATE_TO_LMIN: Record<string, number> = {
"m³/h": 1000 / 60,
"m³/min": 1000,
"m³/s": 60000,
"ft³/min": 28.316846592,
"L/h": 1 / 60,
"L/min": 1,
"L/s": 60,
"gal/h": LITERS_PER_GALLON / 60,
"gal/min": LITERS_PER_GALLON,
"gal/d": LITERS_PER_GALLON / 1440,
"mL/s": 60 / 1000,
};
/**
* Get current flow rate from an entity state, converted to L/min.
* @returns Flow rate in L/min, or undefined if unavailable/invalid.
*/
export const getFlowRateFromState = (
stateObj?: HassEntity
): number | undefined => {
if (!stateObj) {
return undefined;
}
const value = parseFloat(stateObj.state);
if (isNaN(value)) {
return undefined;
}
const unit = stateObj.attributes.unit_of_measurement;
const factor = unit ? FLOW_RATE_TO_LMIN[unit] : undefined;
if (factor === undefined) {
// Unknown unit return raw value as-is (best effort)
return value;
}
return value * factor;
};
/**
* Format a flow rate value (in L/min) to a human-readable string using
* the preferred unit system: metric → L/min, imperial → gal/min.
*/
export const formatFlowRateShort = (
hassLocale: HomeAssistant["locale"],
lengthUnitSystem: string,
litersPerMin: number
): string => {
const isMetric = lengthUnitSystem === "km";
if (isMetric) {
return `${formatNumber(litersPerMin, hassLocale, { maximumFractionDigits: 1 })} L/min`;
}
const galPerMin = litersPerMin / LITERS_PER_GALLON;
return `${formatNumber(galPerMin, hassLocale, { maximumFractionDigits: 1 })} gal/min`;
};
/**
* Get current power value from entity state, normalized to watts (W)
* @param stateObj - The entity state object to get power value from

View File

@@ -3,7 +3,6 @@ import { formatDurationDigital } from "../../common/datetime/format_duration";
import type { FrontendLocaleData } from "../translation";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
// These attributes are hidden from the more-info window for all entities.
export const STATE_ATTRIBUTES = [
"entity_id",
"assumed_state",
@@ -29,8 +28,6 @@ export const STATE_ATTRIBUTES = [
"available_tones",
];
// These attributes are hidden from the more-info window for entities of the
// matching domain and device_class.
export const STATE_ATTRIBUTES_DOMAIN_CLASS = {
sensor: {
enum: ["options"],

View File

@@ -37,6 +37,11 @@ export interface LovelaceViewHeaderConfig {
badges_wrap?: "wrap" | "scroll";
}
export interface LovelaceViewFooterConfig {
card?: LovelaceCardConfig;
column_span?: number;
}
export interface LovelaceViewSidebarConfig {
sections?: LovelaceSectionConfig[];
content_label?: string;
@@ -68,6 +73,7 @@ export interface LovelaceViewConfig extends LovelaceBaseViewConfig {
cards?: LovelaceCardConfig[];
sections?: LovelaceSectionRawConfig[];
header?: LovelaceViewHeaderConfig;
footer?: LovelaceViewFooterConfig;
// Only used for section view, it should move to a section view config type when the views will have dedicated editor.
sidebar?: LovelaceViewSidebarConfig;
}

View File

@@ -8,12 +8,17 @@ import {
mdiPlayBoxMultiple,
mdiTooltipAccount,
} from "@mdi/js";
import type { HomeAssistant, PanelInfo } from "../types";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import type { LocalizeKeys } from "../common/translations/localize";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import type { HomeAssistant, PanelInfo } from "../types";
export const HOME_PANEL = "home";
export const NOT_FOUND_PANEL = "notfound";
export const PROFILE_PANEL = "profile";
export const LOVELACE_PANEL = "lovelace";
/** Panel to show when no panel is picked. */
export const DEFAULT_PANEL = "home";
export const DEFAULT_PANEL = HOME_PANEL;
export const hasLegacyOverviewPanel = (hass: HomeAssistant): boolean =>
Boolean(hass.panels.lovelace?.config);
@@ -30,7 +35,7 @@ export const getDefaultPanelUrlPath = (hass: HomeAssistant): string => {
getLegacyDefaultPanelUrlPath() ||
DEFAULT_PANEL;
// If default panel is lovelace and no old overview exists, fall back to home
if (defaultPanel === "lovelace" && !hasLegacyOverviewPanel(hass)) {
if (defaultPanel === LOVELACE_PANEL && !hasLegacyOverviewPanel(hass)) {
return DEFAULT_PANEL;
}
return defaultPanel;
@@ -39,12 +44,16 @@ export const getDefaultPanelUrlPath = (hass: HomeAssistant): string => {
export const getDefaultPanel = (hass: HomeAssistant): PanelInfo => {
const panel = getDefaultPanelUrlPath(hass);
return (panel ? hass.panels[panel] : undefined) ?? hass.panels[DEFAULT_PANEL];
return (
(panel ? hass.panels[panel] : undefined) ??
hass.panels[DEFAULT_PANEL] ??
hass.panels[NOT_FOUND_PANEL]
);
};
export const getPanelNameTranslationKey = (panel: PanelInfo) => {
if (panel.url_path === "profile") {
return "panel.profile" as const;
if ([PROFILE_PANEL, NOT_FOUND_PANEL].includes(panel.url_path)) {
return `panel.${panel.url_path}` as const;
}
return `panel.${panel.title}` as const;
@@ -137,4 +146,22 @@ export const PANEL_ICON_PATHS = {
export const getPanelIconPath = (panel: PanelInfo): string | undefined =>
PANEL_ICON_PATHS[panel.url_path];
export const FIXED_PANELS = ["profile", "config"];
export const FIXED_PANELS = [PROFILE_PANEL, "config", NOT_FOUND_PANEL];
export interface PanelMutableParams {
title?: string | null;
icon?: string | null;
require_admin?: boolean | null;
show_in_sidebar?: boolean | null;
}
export const updatePanel = (
hass: HomeAssistant,
urlPath: string,
updates: PanelMutableParams
) =>
hass.callWS({
type: "frontend/update_panel",
url_path: urlPath,
...updates,
});

View File

@@ -1,136 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeAttributeNameDisplay } from "../../common/entity/compute_attribute_display";
import "../../components/ha-attribute-value";
import "../../components/ha-card";
import { computeShownAttributes } from "../../data/entity/entity_attributes";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
interface AttributesViewParams {
entityId: string;
}
@customElement("ha-more-info-attributes")
class HaMoreInfoAttributes extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entry?: ExtEntityRegistryEntry | null;
@property({ attribute: false }) public params?: AttributesViewParams;
@state() private _stateObj?: HassEntity;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("params") || changedProps.has("hass")) {
if (this.params?.entityId && this.hass) {
this._stateObj = this.hass.states[this.params.entityId];
}
}
}
protected render() {
if (!this.params || !this._stateObj) {
return nothing;
}
const attributes = computeShownAttributes(this._stateObj);
return html`
<div class="content">
<ha-card>
<div class="card-content">
${attributes.map(
(attribute) => html`
<div class="data-entry">
<div class="key">
${computeAttributeNameDisplay(
this.hass.localize,
this._stateObj!,
this.hass.entities,
attribute
)}
</div>
<div class="value">
<ha-attribute-value
.hass=${this.hass}
.attribute=${attribute}
.stateObj=${this._stateObj}
></ha-attribute-value>
</div>
</div>
`
)}
</div>
</ha-card>
${this._stateObj.attributes.attribution
? html`
<div class="attribution">
${this._stateObj.attributes.attribution}
</div>
`
: nothing}
</div>
`;
}
static styles: CSSResultGroup = css`
:host {
display: flex;
flex-direction: column;
flex: 1;
}
.content {
padding: var(--ha-space-6);
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-6));
}
ha-card {
direction: ltr;
}
.card-content {
padding: var(--ha-space-2) var(--ha-space-4);
}
.data-entry {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: var(--ha-space-2) 0;
border-bottom: 1px solid var(--divider-color);
}
.data-entry:last-of-type {
border-bottom: none;
}
.data-entry .value {
max-width: 60%;
overflow-wrap: break-word;
text-align: right;
}
.key {
flex-grow: 1;
color: var(--secondary-text-color);
}
.attribution {
color: var(--secondary-text-color);
text-align: center;
margin-top: var(--ha-space-4);
font-size: var(--ha-font-size-s);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-attributes": HaMoreInfoAttributes;
}
}

View File

@@ -0,0 +1,189 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeAttributeNameDisplay } from "../../common/entity/compute_attribute_display";
import "../../components/ha-attribute-value";
import "../../components/ha-card";
import { computeShownAttributes } from "../../data/entity/entity_attributes";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
interface DetailsViewParams {
entityId: string;
}
@customElement("ha-more-info-details")
class HaMoreInfoDetails extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entry?: ExtEntityRegistryEntry | null;
@property({ attribute: false }) public params?: DetailsViewParams;
@state() private _stateObj?: HassEntity;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("params") || changedProps.has("hass")) {
if (this.params?.entityId && this.hass) {
this._stateObj = this.hass.states[this.params.entityId];
}
}
}
protected render() {
if (!this.params || !this._stateObj) {
return nothing;
}
const translatedState = this.hass.formatEntityState(this._stateObj);
const detailsAttributes = computeShownAttributes(this._stateObj);
const detailsAttributeSet = new Set(detailsAttributes);
const builtInAttributes = Object.keys(this._stateObj.attributes).filter(
(attribute) => !detailsAttributeSet.has(attribute)
);
const allAttributes = [...detailsAttributes, ...builtInAttributes];
return html`
<div class="content">
<section class="section">
<h2 class="section-title">
${this.hass.localize(
"ui.components.entity.entity-state-picker.state"
)}
</h2>
<ha-card>
<div class="card-content">
<div class="attribute-group">
<div class="data-entry">
<div class="key">
${this.hass.localize(
"ui.dialogs.more_info_control.translated"
)}
</div>
<div class="value">${translatedState}</div>
</div>
<div class="data-entry">
<div class="key">
${this.hass.localize("ui.dialogs.more_info_control.raw")}
</div>
<div class="value">${this._stateObj.state}</div>
</div>
</div>
</div>
</ha-card>
</section>
<section class="section">
<h2 class="section-title">
${this.hass.localize("ui.dialogs.more_info_control.attributes")}
</h2>
<ha-card>
<div class="card-content">
<div class="attribute-group">
${this._renderAttributes(allAttributes)}
</div>
</div>
</ha-card>
</section>
</div>
`;
}
private _renderAttributes(attributes: string[]) {
if (attributes.length === 0) {
return html`<div class="empty">
${this.hass.localize("ui.common.none")}
</div>`;
}
return attributes.map(
(attribute) => html`
<div class="data-entry">
<div class="key">
${computeAttributeNameDisplay(
this.hass.localize,
this._stateObj!,
this.hass.entities,
attribute
)}
</div>
<div class="value">
<ha-attribute-value
.hass=${this.hass}
.attribute=${attribute}
.stateObj=${this._stateObj}
></ha-attribute-value>
</div>
</div>
`
);
}
static styles: CSSResultGroup = css`
:host {
display: flex;
flex-direction: column;
flex: 1;
}
.content {
padding: var(--ha-space-6);
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-6));
}
.section + .section {
margin-top: var(--ha-space-4);
}
.section-title {
margin: 0 0 var(--ha-space-2);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
}
ha-card {
direction: ltr;
}
.card-content {
padding: var(--ha-space-2) var(--ha-space-4);
}
.data-entry {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: var(--ha-space-2) 0;
border-bottom: 1px solid var(--divider-color);
}
.attribute-group .data-entry:last-of-type {
border-bottom: none;
}
.data-entry .value {
max-width: 60%;
overflow-wrap: break-word;
text-align: right;
}
.key {
flex-grow: 1;
color: var(--secondary-text-color);
}
.empty {
color: var(--secondary-text-color);
text-align: center;
padding: var(--ha-space-2) 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-details": HaMoreInfoDetails;
}
}

View File

@@ -44,7 +44,6 @@ import "../../components/ha-dropdown-item";
import "../../components/ha-icon-button";
import "../../components/ha-icon-button-prev";
import "../../components/ha-related-items";
import { computeShownAttributes } from "../../data/entity/entity_attributes";
import type {
EntityRegistryEntry,
ExtEntityRegistryEntry,
@@ -344,31 +343,21 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
case "info":
this._resetInitialView();
break;
case "attributes":
this._showAttributes();
case "details":
this._showDetails();
break;
}
}
private _showAttributes(): void {
import("./ha-more-info-attributes");
private _showDetails(): void {
import("./ha-more-info-details");
this._childView = {
viewTag: "ha-more-info-attributes",
viewTag: "ha-more-info-details",
viewTitle: this.hass.localize("ui.dialogs.more_info_control.details"),
viewParams: { entityId: this._entityId },
};
}
private _hasDisplayableAttributes(): boolean {
if (!this._entityId) {
return false;
}
const stateObj = this.hass.states[this._entityId];
if (!stateObj) {
return false;
}
return computeShownAttributes(stateObj).length > 0;
}
private _goToAddEntityTo(ev) {
// Only check for request-selected events (from menu items), not regular clicks (from icon button)
if (ev.type === "request-selected" && !shouldHandleRequestSelectedEvent(ev))
@@ -590,19 +579,15 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
"ui.dialogs.more_info_control.related"
)}
</ha-dropdown-item>
${this._hasDisplayableAttributes()
? html`
<ha-dropdown-item value="attributes">
<ha-svg-icon
slot="icon"
.path=${mdiFormatListBulletedSquare}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.attributes"
)}
</ha-dropdown-item>
`
: nothing}
<ha-dropdown-item value="details">
<ha-svg-icon
slot="icon"
.path=${mdiFormatListBulletedSquare}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.details"
)}
</ha-dropdown-item>
${this._shouldShowAddEntityTo()
? html`
<ha-dropdown-item value="add_to">

View File

@@ -32,21 +32,28 @@ const initRouting = () => {
new CacheFirst({ matchOptions: { ignoreSearch: true } })
);
// Cache any brand images used for 30 days
// Use revalidation so cache is always available during an extended outage
// Cache any brand images used for 1 day
// Brands are proxied via the local API with backend caching.
// Strip the rotating access token from cache keys so token rotation
// doesn't bust the cache, while preserving other params like "placeholder".
registerRoute(
({ url, request }) =>
url.origin === "https://brands.home-assistant.io" &&
url.pathname.startsWith("/api/brands/") &&
request.destination === "image",
new StaleWhileRevalidate({
cacheName: "brands",
// CORS must be forced to work for CSS images
fetchOptions: { mode: "cors", credentials: "omit" },
plugins: [
{
cacheKeyWillBeUsed: async ({ request }) => {
const url = new URL(request.url);
url.searchParams.delete("token");
return url.href;
},
},
// Add 404 so we quickly respond to domains with missing images
new CacheableResponsePlugin({ statuses: [0, 200, 404] }),
new ExpirationPlugin({
maxAgeSeconds: 60 * 60 * 24 * 30,
maxAgeSeconds: 60 * 60 * 24,
purgeOnQuotaError: true,
}),
],

View File

@@ -51,8 +51,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ type: Boolean }) public supervisor = false;
@property({ type: Boolean, attribute: "main-page" }) public mainPage = false;
@property({ attribute: false }) public initialCollapsedGroups: string[] = [];
@@ -322,7 +320,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
? html`
<ha-dropdown-item
.value=${id}
.clickAction=${this._handleGroupBy}
.selected=${id === this._groupColumn}
class=${classMap({ selected: id === this._groupColumn })}
>
@@ -383,7 +380,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
.route=${this.route}
.tabs=${this.tabs}
.mainPage=${this.mainPage}
.supervisor=${this.supervisor}
.pane=${showPane && this.showFilters}
@sorting-changed=${this._sortingChanged}
>
@@ -489,7 +485,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
: ""}
<ha-data-table
.hass=${this.hass}
.localize=${localize}
.narrow=${this.narrow}
.columns=${this.columns}
.data=${this.data}

View File

@@ -5,7 +5,8 @@ import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { canShowPage } from "../common/config/can_show_page";
import { restoreScroll } from "../common/decorators/restore-scroll";
import { goBack } from "../common/navigate";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { goBack, navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
@@ -14,6 +15,11 @@ import "../components/ha-tab";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant, Route } from "../types";
const normalizePathname = (pathname: string): string =>
pathname.endsWith("/") && pathname.length > 1
? pathname.slice(0, -1)
: pathname;
export interface PageNavigation {
path: string;
translationKey?: string;
@@ -88,9 +94,8 @@ class HassTabsSubpage extends LitElement {
return shownTabs.map(
(page) => html`
<a href=${page.path}>
<a href=${page.path} @click=${this._tabClicked}>
<ha-tab
.hass=${this.hass}
.active=${page.path === activeTab?.path}
.narrow=${this.narrow}
.name=${page.translationKey
@@ -112,8 +117,9 @@ class HassTabsSubpage extends LitElement {
public willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has("route")) {
const currentPath = `${this.route.prefix}${this.route.path}`;
this._activeTab = this.tabs.find((tab) =>
`${this.route.prefix}${this.route.path}`.includes(tab.path)
this._isActiveTabPath(tab.path, currentPath)
);
}
super.willUpdate(changedProperties);
@@ -209,6 +215,36 @@ class HassTabsSubpage extends LitElement {
goBack();
}
private _isActiveTabPath(tabPath: string, currentPath: string): boolean {
try {
const tabUrl = new URL(tabPath, window.location.origin);
const currentUrl = new URL(currentPath, window.location.origin);
const tabPathname = normalizePathname(tabUrl.pathname);
const currentPathname = normalizePathname(currentUrl.pathname);
if (
currentPathname === tabPathname ||
currentPathname.startsWith(`${tabPathname}/`)
) {
return true;
}
return false;
} catch (_err) {
return currentPath === tabPath || currentPath.startsWith(`${tabPath}/`);
}
}
private async _tabClicked(ev: MouseEvent): Promise<void> {
const href = isNavigationClick(ev);
if (!href) {
return;
}
await navigate(href, { replace: true });
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,

View File

@@ -35,6 +35,7 @@ const COMPONENTS = {
security: () => import("../panels/security/ha-panel-security"),
climate: () => import("../panels/climate/ha-panel-climate"),
home: () => import("../panels/home/ha-panel-home"),
notfound: () => import("../panels/notfound/ha-panel-notfound"),
};
@customElement("partial-panel-resolver")

View File

@@ -1,12 +1,13 @@
import { mdiMenu } from "@mdi/js";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { createRef, ref } from "lit/directives/ref";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { navigate } from "../../common/navigate";
import { computeRouteTail } from "../../common/url/route";
import { nextRender } from "../../common/util/render-status";
import "../../components/ha-icon-button";
import type { HassioAddonDetails } from "../../data/hassio/addon";
@@ -24,7 +25,6 @@ import {
showConfirmationDialog,
} from "../../dialogs/generic/show-dialog-box";
import "../../layouts/hass-loading-screen";
import { computeRouteTail } from "../../common/url/route";
import type { HomeAssistant, PanelInfo, Route } from "../../types";
interface AppPanelConfig {
@@ -43,7 +43,7 @@ class HaPanelApp extends LitElement {
@property({ attribute: false }) public panel!: PanelInfo<AppPanelConfig>;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _addon?: HassioAddonDetails;
@@ -119,7 +119,7 @@ class HaPanelApp extends LitElement {
${!this._kioskMode &&
(this.narrow || this.hass.dockedSidebar === "always_hidden")
? html`
<div class="header ${classMap({ narrow: this.narrow })}">
<div class="header">
<ha-icon-button
.label=${this.hass.localize("ui.sidebar.sidebar_toggle")}
.path=${mdiMenu}
@@ -130,7 +130,10 @@ class HaPanelApp extends LitElement {
`
: nothing}
<iframe
class=${classMap({ loaded: this._iframeLoaded })}
class=${classMap({
loaded: this._iframeLoaded,
"kiosk-mode": this._kioskMode,
})}
title=${this._addon.name}
src=${this._addon.ingress_url!}
@load=${this._checkLoaded}
@@ -451,6 +454,16 @@ class HaPanelApp extends LitElement {
height: calc(100% - 40px);
}
:host([narrow]) iframe {
padding-top: var(--safe-area-inset-top);
height: calc(100% - var(--safe-area-inset-top, 0px));
}
:host([narrow]) .header + iframe {
padding-top: 0;
height: calc(100% - 40px - var(--safe-area-inset-top, 0px));
}
.header {
display: flex;
align-items: center;
@@ -466,6 +479,11 @@ class HaPanelApp extends LitElement {
--mdc-icon-size: 20px;
}
:host([narrow]) .header {
height: calc(40px + var(--safe-area-inset-top, 0px));
padding-top: var(--safe-area-inset-top, 0);
}
.main-title {
margin-inline-start: var(--ha-space-6);
line-height: var(--ha-line-height-condensed);

View File

@@ -58,7 +58,8 @@ class PanelClimate extends LitElement {
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors
oldHass.floors !== this.hass.floors ||
oldHass.panels !== this.hass.panels
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();

View File

@@ -103,10 +103,12 @@ const processAreasForClimate = (
heading_style: "subtitle",
type: "heading",
heading: area.name,
tap_action: {
action: "navigate",
navigation_path: `/home/areas-${area.area_id}`,
},
tap_action: hass.panels.home
? {
action: "navigate",
navigation_path: `/home/areas-${area.area_id}`,
}
: undefined,
});
cards.push(...areaCards);
}

View File

@@ -106,12 +106,11 @@ export class HaConfigApplicationCredentials extends LitElement {
filterable: true,
},
actions: {
lastFixed: true,
title: "",
label: localize("ui.panel.config.generic.headers.actions"),
type: "overflow-menu",
showNarrow: true,
hideable: false,
moveable: false,
template: (credential) => html`
<ha-icon-overflow-menu
.hass=${this.hass}

View File

@@ -345,12 +345,11 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
`,
},
actions: {
lastFixed: true,
title: "",
label: this.hass.localize("ui.panel.config.generic.headers.actions"),
type: "icon-button",
showNarrow: true,
moveable: false,
hideable: false,
template: (automation) => html`
<ha-icon-button
.automation=${automation}

View File

@@ -27,7 +27,6 @@ export class HaNumericStateTrigger extends LitElement {
private _schema = memoizeOne(
(
localize: LocalizeFunc,
entityId: string | string[],
inputAboveIsEntity?: boolean,
inputBelowIsEntity?: boolean
) =>
@@ -39,9 +38,9 @@ export class HaNumericStateTrigger extends LitElement {
},
{
name: "attribute",
context: { filter_entity: "entity_id" },
selector: {
attribute: {
entity_id: entityId ? entityId[0] : undefined,
hide_attributes: [
"access_token",
"auto_update",
@@ -275,7 +274,6 @@ export class HaNumericStateTrigger extends LitElement {
public render() {
const schema = this._schema(
this.hass.localize,
this.trigger.entity_id,
this._inputAboveIsEntity,
this._inputBelowIsEntity
);

View File

@@ -255,11 +255,10 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
},
},
actions: {
lastFixed: true,
title: "",
label: localize("ui.panel.config.generic.headers.actions"),
showNarrow: true,
moveable: false,
hideable: false,
type: "overflow-menu",
template: (backup) => html`
<ha-icon-button

View File

@@ -232,12 +232,11 @@ class HaBlueprintOverview extends LitElement {
hidden: true,
},
actions: {
lastFixed: true,
title: "",
label: this.hass.localize("ui.panel.config.generic.headers.actions"),
type: "overflow-menu",
showNarrow: true,
moveable: false,
hideable: false,
template: (blueprint) =>
blueprint.error
? html`<ha-svg-icon

View File

@@ -20,6 +20,7 @@ import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { EnergySettingsDeviceWaterDialogParams } from "./show-dialogs-energy";
const volumeUnitClasses = ["volume"];
const flowRateUnitClasses = ["volume_flow_rate"];
@customElement("dialog-energy-device-settings-water")
export class DialogEnergyDeviceSettingsWater
@@ -36,10 +37,14 @@ export class DialogEnergyDeviceSettingsWater
@state() private _volume_units?: string[];
@state() private _flow_rate_units?: string[];
@state() private _error?: string;
private _excludeList?: string[];
private _excludeListFlowRate?: string[];
private _possibleParents: DeviceConsumptionEnergyPreference[] = [];
public async showDialog(
@@ -51,9 +56,15 @@ export class DialogEnergyDeviceSettingsWater
this._volume_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "water")
).units;
this._flow_rate_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "volume_flow_rate")
).units;
this._excludeList = this._params.device_consumptions
.map((entry) => entry.stat_consumption)
.filter((id) => id !== this._device?.stat_consumption);
this._excludeListFlowRate = this._params.device_consumptions
.map((entry) => entry.stat_rate)
.filter((id) => id && id !== this._device?.stat_rate) as string[];
this._open = true;
}
@@ -92,6 +103,7 @@ export class DialogEnergyDeviceSettingsWater
this._device = undefined;
this._error = undefined;
this._excludeList = undefined;
this._excludeListFlowRate = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -134,12 +146,6 @@ export class DialogEnergyDeviceSettingsWater
@closed=${this._dialogClosed}
>
${this._error ? html`<p class="error">${this._error}</p>` : ""}
<div>
${this.hass.localize(
"ui.panel.config.energy.device_consumption_water.dialog.selected_stat_intro",
{ unit: pickableUnit }
)}
</div>
<ha-statistic-picker
.hass=${this.hass}
@@ -151,9 +157,28 @@ export class DialogEnergyDeviceSettingsWater
)}
.excludeStatistics=${this._excludeList}
@value-changed=${this._statisticChanged}
.helper=${this.hass.localize(
"ui.panel.config.energy.device_consumption_water.dialog.selected_stat_intro",
{ unit: pickableUnit }
)}
autofocus
></ha-statistic-picker>
<ha-statistic-picker
.hass=${this.hass}
.includeUnitClass=${flowRateUnitClasses}
.value=${this._device?.stat_rate}
.label=${this.hass.localize(
"ui.panel.config.energy.device_consumption_water.dialog.device_consumption_water_flow_rate"
)}
.excludeStatistics=${this._excludeListFlowRate}
@value-changed=${this._flowRateStatisticChanged}
.helper=${this.hass.localize(
"ui.panel.config.energy.device_consumption_water.dialog.selected_stat_intro",
{ unit: this._flow_rate_units?.join(", ") || "" }
)}
></ha-statistic-picker>
<ha-textfield
.label=${this.hass.localize(
"ui.panel.config.energy.device_consumption_water.dialog.display_name"
@@ -216,6 +241,20 @@ export class DialogEnergyDeviceSettingsWater
this._computePossibleParents();
}
private _flowRateStatisticChanged(ev: ValueChangedEvent<string>) {
if (!this._device) {
return;
}
const newDevice = {
...this._device,
stat_rate: ev.detail.value,
} as DeviceConsumptionEnergyPreference;
if (!newDevice.stat_rate) {
delete newDevice.stat_rate;
}
this._device = newDevice;
}
private _nameChanged(ev) {
const newDevice = {
...this._device!,
@@ -252,7 +291,9 @@ export class DialogEnergyDeviceSettingsWater
haStyleDialog,
css`
ha-statistic-picker {
display: block;
width: 100%;
margin-bottom: var(--ha-space-4);
}
ha-select {
display: block;

View File

@@ -30,6 +30,7 @@ import type { EnergySettingsGasDialogParams } from "./show-dialogs-energy";
const gasDeviceClasses = ["gas", "energy"];
const gasUnitClasses = ["volume", "energy"];
const flowRateUnitClasses = ["volume_flow_rate"];
@customElement("dialog-energy-gas-settings")
export class DialogEnergyGasSettings
@@ -52,10 +53,14 @@ export class DialogEnergyGasSettings
@state() private _gas_units?: string[];
@state() private _flow_rate_units?: string[];
@state() private _error?: string;
private _excludeList?: string[];
private _excludeListFlowRate?: string[];
public async showDialog(
params: EnergySettingsGasDialogParams
): Promise<void> {
@@ -81,9 +86,15 @@ export class DialogEnergyGasSettings
this._gas_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "gas")
).units;
this._flow_rate_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "volume_flow_rate")
).units;
this._excludeList = this._params.gas_sources
.map((entry) => entry.stat_energy_from)
.filter((id) => id !== this._source?.stat_energy_from);
this._excludeListFlowRate = this._params.gas_sources
.map((entry) => entry.stat_rate)
.filter((id) => id && id !== this._source?.stat_rate) as string[];
this._open = true;
}
@@ -99,6 +110,7 @@ export class DialogEnergyGasSettings
this._pickedDisplayUnit = undefined;
this._error = undefined;
this._excludeList = undefined;
this._excludeListFlowRate = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -146,12 +158,6 @@ export class DialogEnergyGasSettings
<p>
${this.hass.localize("ui.panel.config.energy.gas.dialog.paragraph")}
</p>
<p>
${this.hass.localize(
"ui.panel.config.energy.gas.dialog.entity_para",
{ unit: pickableUnit }
)}
</p>
<p>
${this.hass.localize("ui.panel.config.energy.gas.dialog.note_para")}
</p>
@@ -169,9 +175,28 @@ export class DialogEnergyGasSettings
)}
.excludeStatistics=${this._excludeList}
@value-changed=${this._statisticChanged}
.helper=${this.hass.localize(
"ui.panel.config.energy.gas.dialog.entity_para",
{ unit: pickableUnit }
)}
autofocus
></ha-statistic-picker>
<ha-statistic-picker
.hass=${this.hass}
.includeUnitClass=${flowRateUnitClasses}
.value=${this._source.stat_rate}
.label=${this.hass.localize(
"ui.panel.config.energy.gas.dialog.gas_flow_rate"
)}
.excludeStatistics=${this._excludeListFlowRate}
@value-changed=${this._flowRateStatisticChanged}
.helper=${this.hass.localize(
"ui.panel.config.energy.gas.dialog.flow_rate_para",
{ unit: this._flow_rate_units?.join(", ") || "" }
)}
></ha-statistic-picker>
<p>
${this.hass.localize("ui.panel.config.energy.gas.dialog.cost_para")}
</p>
@@ -341,6 +366,13 @@ export class DialogEnergyGasSettings
};
}
private _flowRateStatisticChanged(ev: ValueChangedEvent<string>) {
this._source = {
...this._source!,
stat_rate: ev.detail.value || undefined,
};
}
private async _statisticChanged(ev: ValueChangedEvent<string>) {
if (ev.detail.value) {
const metadata = await getStatisticMetadata(this.hass, [ev.detail.value]);
@@ -380,6 +412,10 @@ export class DialogEnergyGasSettings
haStyle,
haStyleDialog,
css`
ha-statistic-picker {
display: block;
margin-bottom: var(--ha-space-4);
}
ha-formfield {
display: block;
}

View File

@@ -24,6 +24,8 @@ import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { EnergySettingsWaterDialogParams } from "./show-dialogs-energy";
const flowRateUnitClasses = ["volume_flow_rate"];
@customElement("dialog-energy-water-settings")
export class DialogEnergyWaterSettings
extends LitElement
@@ -41,10 +43,14 @@ export class DialogEnergyWaterSettings
@state() private _water_units?: string[];
@state() private _flow_rate_units?: string[];
@state() private _error?: string;
private _excludeList?: string[];
private _excludeListFlowRate?: string[];
public async showDialog(
params: EnergySettingsWaterDialogParams
): Promise<void> {
@@ -62,9 +68,15 @@ export class DialogEnergyWaterSettings
this._water_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "water")
).units;
this._flow_rate_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "volume_flow_rate")
).units;
this._excludeList = this._params.water_sources
.map((entry) => entry.stat_energy_from)
.filter((id) => id !== this._source?.stat_energy_from);
this._excludeListFlowRate = this._params.water_sources
.map((entry) => entry.stat_rate)
.filter((id) => id && id !== this._source?.stat_rate) as string[];
this._open = true;
}
@@ -79,6 +91,7 @@ export class DialogEnergyWaterSettings
this._source = undefined;
this._error = undefined;
this._excludeList = undefined;
this._excludeListFlowRate = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -108,19 +121,6 @@ export class DialogEnergyWaterSettings
@closed=${this._dialogClosed}
>
${this._error ? html`<p class="error">${this._error}</p>` : ""}
<div>
<p>
${this.hass.localize(
"ui.panel.config.energy.water.dialog.paragraph"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.energy.water.dialog.entity_para",
{ unit: pickableUnit }
)}
</p>
</div>
<ha-statistic-picker
.hass=${this.hass}
@@ -133,9 +133,28 @@ export class DialogEnergyWaterSettings
)}
.excludeStatistics=${this._excludeList}
@value-changed=${this._statisticChanged}
.helper=${this.hass.localize(
"ui.panel.config.energy.water.dialog.entity_para",
{ unit: pickableUnit }
)}
autofocus
></ha-statistic-picker>
<ha-statistic-picker
.hass=${this.hass}
.includeUnitClass=${flowRateUnitClasses}
.value=${this._source.stat_rate}
.label=${this.hass.localize(
"ui.panel.config.energy.water.dialog.water_flow_rate"
)}
.excludeStatistics=${this._excludeListFlowRate}
@value-changed=${this._flowRateStatisticChanged}
.helper=${this.hass.localize(
"ui.panel.config.energy.water.dialog.flow_rate_para",
{ unit: this._flow_rate_units?.join(", ") || "" }
)}
></ha-statistic-picker>
<p>
${this.hass.localize("ui.panel.config.energy.water.dialog.cost_para")}
</p>
@@ -287,6 +306,13 @@ export class DialogEnergyWaterSettings
};
}
private _flowRateStatisticChanged(ev: ValueChangedEvent<string>) {
this._source = {
...this._source!,
stat_rate: ev.detail.value || undefined,
};
}
private async _statisticChanged(ev: ValueChangedEvent<string>) {
if (
ev.detail.value &&
@@ -320,6 +346,10 @@ export class DialogEnergyWaterSettings
haStyle,
haStyleDialog,
css`
ha-statistic-picker {
display: block;
margin-bottom: var(--ha-space-4);
}
ha-formfield {
display: block;
}

View File

@@ -1,8 +1,10 @@
import "../../../layouts/hass-error-screen";
import { mdiDownload } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { mdiDownload, mdiFire, mdiLightningBolt, mdiWater } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { cache } from "lit/directives/cache";
import { navigate } from "../../../common/navigate";
import type {
EnergyPreferencesValidation,
EnergyInfo,
@@ -17,7 +19,7 @@ import {
import type { StatisticsMetaData } from "../../../data/recorder";
import { getStatisticMetadata } from "../../../data/recorder";
import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-subpage";
import "../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import "../../../components/ha-alert";
@@ -29,6 +31,7 @@ import "./components/ha-energy-battery-settings";
import "./components/ha-energy-gas-settings";
import "./components/ha-energy-water-settings";
import { fileDownload } from "../../../util/file_download";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
const INITIAL_CONFIG: EnergyPreferences = {
energy_sources: [],
@@ -36,6 +39,27 @@ const INITIAL_CONFIG: EnergyPreferences = {
device_consumption_water: [],
};
const TABS: PageNavigation[] = [
{
path: "/config/energy/electricity",
translationKey: "ui.panel.config.energy.tabs.electricity",
iconPath: mdiLightningBolt,
iconColor: "#F1C447",
},
{
path: "/config/energy/gas",
translationKey: "ui.panel.config.energy.tabs.gas",
iconPath: mdiFire,
iconColor: "#F1C447",
},
{
path: "/config/energy/water",
translationKey: "ui.panel.config.energy.tabs.water",
iconPath: mdiWater,
iconColor: "#F1C447",
},
];
@customElement("ha-config-energy")
class HaConfigEnergy extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -60,6 +84,19 @@ class HaConfigEnergy extends LitElement {
@state() private _statsMetadata?: Record<string, StatisticsMetaData>;
private get _currTab(): string {
return this.route.path.substring(1) || "electricity";
}
protected willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has("route")) {
const tab = this.route.path.substring(1);
if (!tab || !TABS.some((t) => t.path.endsWith(`/${tab}`))) {
navigate(`${this.route.prefix}/electricity`, { replace: true });
}
}
}
protected firstUpdated() {
this._fetchConfig();
}
@@ -81,13 +118,14 @@ class HaConfigEnergy extends LitElement {
}
return html`
<hass-subpage
<hass-tabs-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${this._searchParms.has("historyBack")
? undefined
: "/config/lovelace/dashboards"}
.header=${this.hass.localize("ui.panel.config.energy.caption")}
.route=${this.route}
.tabs=${TABS}
>
<ha-icon-button
slot="toolbar-icon"
@@ -100,7 +138,15 @@ class HaConfigEnergy extends LitElement {
<ha-alert>
${this.hass.localize("ui.panel.config.energy.new_device_info")}
</ha-alert>
<div class="content">
<div class="content">${cache(this._renderTabContent())}</div>
</hass-tabs-subpage>
`;
}
private _renderTabContent(): TemplateResult | typeof nothing {
switch (this._currTab) {
case "electricity":
return html`
<ha-energy-grid-settings
.hass=${this.hass}
.preferences=${this._preferences!}
@@ -123,20 +169,6 @@ class HaConfigEnergy extends LitElement {
.validationResult=${this._validationResult}
@value-changed=${this._prefsChanged}
></ha-energy-battery-settings>
<ha-energy-gas-settings
.hass=${this.hass}
.preferences=${this._preferences!}
.statsMetadata=${this._statsMetadata}
.validationResult=${this._validationResult}
@value-changed=${this._prefsChanged}
></ha-energy-gas-settings>
<ha-energy-water-settings
.hass=${this.hass}
.preferences=${this._preferences!}
.statsMetadata=${this._statsMetadata}
.validationResult=${this._validationResult}
@value-changed=${this._prefsChanged}
></ha-energy-water-settings>
<ha-energy-device-settings
.hass=${this.hass}
.preferences=${this._preferences!}
@@ -144,6 +176,26 @@ class HaConfigEnergy extends LitElement {
.validationResult=${this._validationResult}
@value-changed=${this._prefsChanged}
></ha-energy-device-settings>
`;
case "gas":
return html`
<ha-energy-gas-settings
.hass=${this.hass}
.preferences=${this._preferences!}
.statsMetadata=${this._statsMetadata}
.validationResult=${this._validationResult}
@value-changed=${this._prefsChanged}
></ha-energy-gas-settings>
`;
case "water":
return html`
<ha-energy-water-settings
.hass=${this.hass}
.preferences=${this._preferences!}
.statsMetadata=${this._statsMetadata}
.validationResult=${this._validationResult}
@value-changed=${this._prefsChanged}
></ha-energy-water-settings>
<ha-energy-device-settings-water
.hass=${this.hass}
.preferences=${this._preferences!}
@@ -151,9 +203,10 @@ class HaConfigEnergy extends LitElement {
.validationResult=${this._validationResult}
@value-changed=${this._prefsChanged}
></ha-energy-device-settings-water>
</div>
</hass-subpage>
`;
`;
default:
return nothing;
}
}
private async _fetchConfig() {

View File

@@ -380,11 +380,10 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
localize("ui.panel.config.entities.picker.status.unmanageable")
),
actions: {
lastFixed: true,
title: "",
label: this.hass.localize("ui.panel.config.generic.headers.actions"),
type: "overflow-menu",
hideable: false,
moveable: false,
showNarrow: true,
template: (helper) => html`
<ha-icon-overflow-menu

View File

@@ -578,7 +578,6 @@ class AddIntegrationDialog extends LitElement {
}
return html`
<ha-integration-list-item
brand
.hass=${this.hass}
.integration=${integration}
tabindex="0"

View File

@@ -30,8 +30,6 @@ export class HaIntegrationListItem extends ListItemBase {
// eslint-disable-next-line lit/attribute-names
@property({ type: Boolean }) hasMeta = true;
@property({ type: Boolean }) brand = false;
// @ts-expect-error
protected override renderSingleLine() {
if (!this.integration) {
@@ -68,7 +66,6 @@ export class HaIntegrationListItem extends ListItemBase {
domain: this.integration.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
brand: this.brand,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"

View File

@@ -158,6 +158,7 @@ export class BluetoothConfigDashboard extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.bluetooth.title")}
back-path="/config"
>
<div class="container">
<ha-card class="content network-status">

View File

@@ -1,31 +1,31 @@
import { mdiAlertCircle, mdiCheckCircle, mdiPlus } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import {
mdiAlertCircleOutline,
mdiCheck,
mdiDevices,
mdiPlus,
mdiShape,
mdiTune,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-expansion-panel";
import "../../../../../components/ha-fab";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon";
import type { ConfigEntry } from "../../../../../data/config_entries";
import { getConfigEntries } from "../../../../../data/config_entries";
import type { HomeAssistant } from "../../../../../types";
import {
acceptSharedMatterDevice,
canCommissionMatterExternal,
commissionMatterDevice,
matterSetThread,
matterSetWifi,
redirectOnNewMatterDevice,
startExternalCommissioning,
} from "../../../../../data/matter";
import { showPromptDialog } from "../../../../../dialogs/generic/show-dialog-box";
import "../../../../../layouts/hass-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
const THREAD_ICON =
"m 17.126982,8.0730792 c 0,-0.7297242 -0.593746,-1.32357 -1.323637,-1.32357 -0.729454,0 -1.323199,0.5938458 -1.323199,1.32357 v 1.3234242 l 1.323199,1.458e-4 c 0.729891,0 1.323637,-0.5937006 1.323637,-1.32357 z M 11.999709,0 C 5.3829818,0 0,5.3838955 0,12.001455 0,18.574352 5.3105455,23.927406 11.865164,24 V 12.012075 l -3.9275642,-2.91e-4 c -1.1669814,0 -2.1169453,0.949979 -2.1169453,2.118323 0,1.16718 0.9499639,2.116868 2.1169453,2.116868 v 2.615717 c -2.6093089,0 -4.732218,-2.12327 -4.732218,-4.732585 0,-2.61048 2.1229091,-4.7343308 4.732218,-4.7343308 l 3.9275642,5.82e-4 v -1.323279 c 0,-2.172296 1.766691,-3.9395777 3.938181,-3.9395777 2.171928,0 3.9392,1.7672817 3.9392,3.9395777 0,2.1721498 -1.767272,3.9395768 -3.9392,3.9395768 l -1.323199,-1.45e-4 V 23.744102 C 19.911127,22.597726 24,17.768833 24,12.001455 24,5.3838955 18.616727,0 11.999709,0 Z";
@customElement("matter-config-dashboard")
export class MatterConfigDashboard extends LitElement {
@@ -35,15 +35,6 @@ export class MatterConfigDashboard extends LitElement {
@state() private _configEntry?: ConfigEntry;
@state() private _error?: string;
private _unsub?: UnsubscribeFunc;
disconnectedCallback() {
super.disconnectedCallback();
this._stopRedirect();
}
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
if (this.hass) {
@@ -51,10 +42,26 @@ export class MatterConfigDashboard extends LitElement {
}
}
private _matterDeviceCount = memoizeOne(
(devices: HomeAssistant["devices"]): number =>
Object.values(devices).filter((device) =>
device.identifiers.some((identifier) => identifier[0] === "matter")
private _matterDeviceIds = memoizeOne(
(
devices: HomeAssistant["devices"],
configEntryId?: string
): Set<string> => {
if (!configEntryId) {
return new Set();
}
return new Set(
Object.values(devices)
.filter((device) => device.config_entries.includes(configEntryId))
.map((device) => device.id)
);
}
);
private _entityCount = memoizeOne(
(entities: HomeAssistant["entities"], deviceIds: Set<string>): number =>
Object.values(entities).filter(
(entity) => entity.device_id && deviceIds.has(entity.device_id)
).length
);
@@ -63,122 +70,24 @@ export class MatterConfigDashboard extends LitElement {
return nothing;
}
const isOnline = this._configEntry.state === "loaded";
const deviceIds = this._matterDeviceIds(
this.hass.devices,
this._configEntry.entry_id
);
const entityCount = this._entityCount(this.hass.entities, deviceIds);
return html`
<hass-subpage
.narrow=${this.narrow}
.hass=${this.hass}
header="Matter"
back-path="/config"
has-fab
>
${isComponentLoaded(this.hass, "thread")
? html`
<ha-button
appearance="plain"
size="small"
href="/config/thread"
slot="toolbar-icon"
>
${this.hass.localize(
"ui.panel.config.matter.panel.thread_panel"
)}</ha-button
>
`
: nothing}
<div class="container">
<ha-card class="network-status">
<div class="card-content">
<div class="heading">
<div class="icon">
<ha-svg-icon
.path=${isOnline ? mdiCheckCircle : mdiAlertCircle}
class=${isOnline ? "online" : "offline"}
></ha-svg-icon>
</div>
<div class="details">
Matter
${this.hass.localize(
"ui.panel.config.matter.panel.status_title"
)}:
${this.hass.localize(
`ui.panel.config.matter.panel.status_${isOnline ? "online" : "offline"}`
)}<br />
<small>
${this.hass.localize(
"ui.panel.config.matter.panel.devices",
{ count: this._matterDeviceCount(this.hass.devices) }
)}
</small>
</div>
</div>
</div>
<div class="card-actions">
<ha-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
appearance="plain"
size="small"
>
${this.hass.localize("ui.panel.config.devices.caption")}
</ha-button>
<ha-button
appearance="plain"
size="small"
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
${this.hass.localize("ui.panel.config.entities.caption")}
</ha-button>
</div>
</ha-card>
<ha-expansion-panel
outlined
.header=${this.hass.localize(
"ui.panel.config.matter.panel.developer_tools_title"
)}
.secondary=${this.hass.localize(
"ui.panel.config.matter.panel.developer_tools_description"
)}
>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<div class="dev-tools-content">
<p>
${this.hass.localize(
"ui.panel.config.matter.panel.developer_tools_info"
)}
</p>
<div class="dev-tools-actions">
${canCommissionMatterExternal(this.hass)
? html`<ha-button
appearance="plain"
@click=${this._startMobileCommissioning}
>${this.hass.localize(
"ui.panel.config.matter.panel.mobile_app_commisioning"
)}</ha-button
>`
: nothing}
<ha-button appearance="plain" @click=${this._commission}
>${this.hass.localize(
"ui.panel.config.matter.panel.commission_device"
)}</ha-button
>
<ha-button appearance="plain" @click=${this._acceptSharedDevice}
>${this.hass.localize(
"ui.panel.config.matter.panel.add_shared_device"
)}</ha-button
>
<ha-button appearance="plain" @click=${this._setWifi}
>${this.hass.localize(
"ui.panel.config.matter.panel.set_wifi_credentials"
)}</ha-button
>
<ha-button appearance="plain" @click=${this._setThread}
>${this.hass.localize(
"ui.panel.config.matter.panel.set_thread_credentials"
)}</ha-button
>
</div>
</div>
</ha-expansion-panel>
${this._renderNetworkStatus(isOnline, deviceIds.size)}
${this._renderMyNetworkCard(deviceIds.size, entityCount)}
${this._renderNavigationCard()}
</div>
<a href="/config/matter/add" slot="fab">
@@ -195,138 +104,111 @@ export class MatterConfigDashboard extends LitElement {
`;
}
private _redirectOnNewMatterDevice() {
if (this._unsub) {
return;
}
this._unsub = redirectOnNewMatterDevice(this.hass, () => {
this._unsub = undefined;
});
private _renderNetworkStatus(isOnline: boolean, deviceCount: number) {
return html`
<ha-card class="content network-status">
<div class="card-content">
<div class="heading">
<div class="icon ${isOnline ? "success" : "error"}">
<ha-svg-icon
.path=${isOnline ? mdiCheck : mdiAlertCircleOutline}
></ha-svg-icon>
</div>
<div class="details">
${this.hass.localize(
`ui.panel.config.matter.panel.status_${isOnline ? "online" : "offline"}`
)}<br />
<small>
${this.hass.localize("ui.panel.config.matter.panel.devices", {
count: deviceCount,
})}
</small>
</div>
</div>
</div>
</ha-card>
`;
}
private _stopRedirect() {
this._unsub?.();
this._unsub = undefined;
private _renderMyNetworkCard(deviceCount: number, entityCount: number) {
return html`
<ha-card class="nav-card">
<div class="card-header">
${this.hass.localize("ui.panel.config.matter.panel.my_network_title")}
</div>
<div class="card-content">
<ha-md-list>
<ha-md-list-item
type="link"
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.device_count",
{ count: deviceCount }
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item
type="link"
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.entity_count",
{ count: entityCount }
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</div>
</ha-card>
`;
}
private _startMobileCommissioning() {
this._redirectOnNewMatterDevice();
startExternalCommissioning(this.hass);
}
private async _setWifi(): Promise<void> {
this._error = undefined;
const networkName = await showPromptDialog(this, {
title: this.hass.localize(
"ui.panel.config.matter.panel.prompts.network_name.title"
),
inputLabel: this.hass.localize(
"ui.panel.config.matter.panel.prompts.network_name.input_label"
),
inputType: "string",
confirmText: this.hass.localize(
"ui.panel.config.matter.panel.prompts.network_name.confirm"
),
});
if (!networkName) {
return;
}
const psk = await showPromptDialog(this, {
title: this.hass.localize(
"ui.panel.config.matter.panel.prompts.passcode.title"
),
inputLabel: this.hass.localize(
"ui.panel.config.matter.panel.prompts.passcode.input_label"
),
inputType: "password",
confirmText: this.hass.localize(
"ui.panel.config.matter.panel.prompts.passcode.confirm"
),
});
if (!psk) {
return;
}
try {
await matterSetWifi(this.hass, networkName, psk);
} catch (err: any) {
this._error = err.message;
}
}
private async _commission(): Promise<void> {
const code = await showPromptDialog(this, {
title: this.hass.localize(
"ui.panel.config.matter.panel.prompts.commission_device.title"
),
inputLabel: this.hass.localize(
"ui.panel.config.matter.panel.prompts.commission_device.input_label"
),
inputType: "string",
confirmText: this.hass.localize(
"ui.panel.config.matter.panel.prompts.commission_device.confirm"
),
});
if (!code) {
return;
}
this._error = undefined;
this._redirectOnNewMatterDevice();
try {
await commissionMatterDevice(this.hass, code);
} catch (err: any) {
this._error = err.message;
this._stopRedirect();
}
}
private async _acceptSharedDevice(): Promise<void> {
const code = await showPromptDialog(this, {
title: this.hass.localize(
"ui.panel.config.matter.panel.prompts.add_shared_device.title"
),
inputLabel: this.hass.localize(
"ui.panel.config.matter.panel.prompts.add_shared_device.input_label"
),
inputType: "number",
confirmText: this.hass.localize(
"ui.panel.config.matter.panel.prompts.add_shared_device.confirm"
),
});
if (!code) {
return;
}
this._error = undefined;
this._redirectOnNewMatterDevice();
try {
await acceptSharedMatterDevice(this.hass, Number(code));
} catch (err: any) {
this._error = err.message;
this._stopRedirect();
}
}
private async _setThread(): Promise<void> {
const code = await showPromptDialog(this, {
title: this.hass.localize(
"ui.panel.config.matter.panel.prompts.set_thread.title"
),
inputLabel: this.hass.localize(
"ui.panel.config.matter.panel.prompts.set_thread.input_label"
),
inputType: "string",
confirmText: this.hass.localize(
"ui.panel.config.matter.panel.prompts.set_thread.confirm"
),
});
if (!code) {
return;
}
this._error = undefined;
try {
await matterSetThread(this.hass, code);
} catch (err: any) {
this._error = err.message;
}
private _renderNavigationCard() {
return html`
<ha-card class="nav-card">
<div class="card-content">
<ha-md-list>
<ha-md-list-item type="link" href="/config/matter/options">
<ha-svg-icon slot="start" .path=${mdiTune}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.options_title"
)}
</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.matter.panel.options_description"
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
${isComponentLoaded(this.hass, "thread")
? html`<ha-md-list-item type="link" href="/config/thread">
<ha-svg-icon slot="start" .path=${THREAD_ICON}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.thread_panel"
)}
</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.matter.panel.thread_panel_description"
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>`
: nothing}
</ha-md-list>
</div>
</ha-card>
`;
}
private async _fetchConfigEntry(): Promise<void> {
@@ -343,79 +225,95 @@ export class MatterConfigDashboard extends LitElement {
haStyle,
css`
ha-card {
margin: auto;
margin-top: var(--ha-space-4);
max-width: 500px;
margin: 0 auto var(--ha-space-4);
max-width: 600px;
}
ha-card .card-actions {
display: flex;
justify-content: flex-end;
.nav-card {
overflow: hidden;
}
.nav-card .card-content {
padding: 0;
}
.nav-card .card-header {
padding-bottom: var(--ha-space-2);
}
.content {
margin-top: var(--ha-space-6);
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-item-overflow: visible;
}
.network-status div.heading {
display: flex;
align-items: center;
column-gap: var(--ha-space-4);
}
.network-status div.heading .icon {
margin-inline-end: var(--ha-space-4);
position: relative;
border-radius: var(--ha-border-radius-2xl);
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
--icon-color: var(--primary-color);
}
.network-status div.heading ha-svg-icon {
--mdc-icon-size: 48px;
.network-status div.heading .icon.success {
--icon-color: var(--success-color);
}
.network-status div.heading .icon.error {
--icon-color: var(--error-color);
}
.network-status div.heading .icon::before {
display: block;
content: "";
position: absolute;
inset: 0;
background-color: var(--icon-color);
opacity: 0.2;
}
.network-status div.heading .icon ha-svg-icon {
color: var(--icon-color);
width: 24px;
height: 24px;
}
.network-status div.heading .details {
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
color: var(--primary-text-color);
}
.network-status small {
font-size: var(--ha-font-size-m);
}
.network-status .online {
color: var(--state-on-color, var(--success-color));
}
.network-status .offline {
color: var(--error-color, var(--error-color));
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
letter-spacing: 0.25px;
color: var(--secondary-text-color);
}
.container {
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
}
ha-expansion-panel {
margin: auto;
margin-top: var(--ha-space-4);
max-width: 500px;
background: var(--card-background-color);
border-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
--expansion-panel-summary-padding: var(--ha-space-2) var(--ha-space-4);
--expansion-panel-content-padding: 0 var(--ha-space-4);
}
.dev-tools-content {
padding: var(--ha-space-3) 0;
}
.dev-tools-content p {
margin: 0 0 var(--ha-space-4);
color: var(--primary-text-color);
}
.dev-tools-actions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--ha-space-2);
}
a[slot="toolbar-icon"] {
text-decoration: none;
padding: var(--ha-space-2) var(--ha-space-4)
calc(var(--ha-space-16) + var(--safe-area-inset-bottom, 0px));
}
a[slot="fab"] {

View File

@@ -1,23 +1,8 @@
import { mdiServerNetwork, mdiTextBoxOutline } from "@mdi/js";
import { customElement, property } from "lit/decorators";
import type { RouterOptions } from "../../../../../layouts/hass-router-page";
import { HassRouterPage } from "../../../../../layouts/hass-router-page";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../../../../types";
export const configTabs: PageNavigation[] = [
{
translationKey: "ui.panel.config.zwave_js.navigation.network",
path: `/config/zwave_js/dashboard`,
iconPath: mdiServerNetwork,
},
{
translationKey: "ui.panel.config.zwave_js.navigation.logs",
path: `/config/zwave_js/logs`,
iconPath: mdiTextBoxOutline,
},
];
@customElement("matter-config-panel")
class MatterConfigRouter extends HassRouterPage {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -38,6 +23,10 @@ class MatterConfigRouter extends HassRouterPage {
tag: "matter-add-device",
load: () => import("./matter-add-device"),
},
options: {
tag: "matter-options-page",
load: () => import("./matter-options-page"),
},
},
};

View File

@@ -0,0 +1,340 @@
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import {
acceptSharedMatterDevice,
canCommissionMatterExternal,
commissionMatterDevice,
matterSetThread,
matterSetWifi,
redirectOnNewMatterDevice,
startExternalCommissioning,
} from "../../../../../data/matter";
import { showPromptDialog } from "../../../../../dialogs/generic/show-dialog-box";
import "../../../../../layouts/hass-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
@customElement("matter-options-page")
class MatterOptionsPage extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public route!: Route;
@property({ type: Boolean }) public narrow = false;
@state() private _error?: string;
private _unsub?: UnsubscribeFunc;
disconnectedCallback() {
super.disconnectedCallback();
this._stopRedirect();
}
protected render() {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
"ui.panel.config.matter.panel.options_title"
)}
back-path="/config/matter/dashboard"
>
<div class="container">
<ha-card>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<ha-md-list>
${canCommissionMatterExternal(this.hass)
? html`<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.mobile_app_commisioning"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.matter.panel.mobile_app_commisioning_description"
)}
</span>
<ha-button
appearance="plain"
slot="end"
size="small"
@click=${this._startMobileCommissioning}
>
${this.hass.localize(
"ui.panel.config.matter.panel.mobile_app_commisioning_action"
)}
</ha-button>
</ha-md-list-item>`
: nothing}
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.commission_device"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.matter.panel.commission_device_description"
)}
</span>
<ha-button
appearance="plain"
slot="end"
size="small"
@click=${this._commission}
>
${this.hass.localize(
"ui.panel.config.matter.panel.commission_device_action"
)}
</ha-button>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.add_shared_device"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.matter.panel.add_shared_device_description"
)}
</span>
<ha-button
appearance="plain"
slot="end"
size="small"
@click=${this._acceptSharedDevice}
>
${this.hass.localize(
"ui.panel.config.matter.panel.add_shared_device_action"
)}
</ha-button>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.set_wifi_credentials"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.matter.panel.set_wifi_credentials_description"
)}
</span>
<ha-button
appearance="plain"
slot="end"
size="small"
@click=${this._setWifi}
>
${this.hass.localize(
"ui.panel.config.matter.panel.set_wifi_credentials_action"
)}
</ha-button>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.set_thread_credentials"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.matter.panel.set_thread_credentials_description"
)}
</span>
<ha-button
appearance="plain"
slot="end"
size="small"
@click=${this._setThread}
>
${this.hass.localize(
"ui.panel.config.matter.panel.set_thread_credentials_action"
)}
</ha-button>
</ha-md-list-item>
</ha-md-list>
</ha-card>
</div>
</hass-subpage>
`;
}
private _redirectOnNewMatterDevice() {
if (this._unsub) {
return;
}
this._unsub = redirectOnNewMatterDevice(this.hass, () => {
this._unsub = undefined;
});
}
private _stopRedirect() {
this._unsub?.();
this._unsub = undefined;
}
private _startMobileCommissioning() {
this._redirectOnNewMatterDevice();
startExternalCommissioning(this.hass);
}
private async _commission(): Promise<void> {
const code = await showPromptDialog(this, {
title: this.hass.localize(
"ui.panel.config.matter.panel.prompts.commission_device.title"
),
inputLabel: this.hass.localize(
"ui.panel.config.matter.panel.prompts.commission_device.input_label"
),
inputType: "string",
confirmText: this.hass.localize(
"ui.panel.config.matter.panel.prompts.commission_device.confirm"
),
});
if (!code) {
return;
}
this._error = undefined;
this._redirectOnNewMatterDevice();
try {
await commissionMatterDevice(this.hass, code);
} catch (err: any) {
this._error = err.message;
this._stopRedirect();
}
}
private async _acceptSharedDevice(): Promise<void> {
const code = await showPromptDialog(this, {
title: this.hass.localize(
"ui.panel.config.matter.panel.prompts.add_shared_device.title"
),
inputLabel: this.hass.localize(
"ui.panel.config.matter.panel.prompts.add_shared_device.input_label"
),
inputType: "number",
confirmText: this.hass.localize(
"ui.panel.config.matter.panel.prompts.add_shared_device.confirm"
),
});
if (!code) {
return;
}
this._error = undefined;
this._redirectOnNewMatterDevice();
try {
await acceptSharedMatterDevice(this.hass, Number(code));
} catch (err: any) {
this._error = err.message;
this._stopRedirect();
}
}
private async _setWifi(): Promise<void> {
this._error = undefined;
const networkName = await showPromptDialog(this, {
title: this.hass.localize(
"ui.panel.config.matter.panel.prompts.network_name.title"
),
inputLabel: this.hass.localize(
"ui.panel.config.matter.panel.prompts.network_name.input_label"
),
inputType: "string",
confirmText: this.hass.localize(
"ui.panel.config.matter.panel.prompts.network_name.confirm"
),
});
if (!networkName) {
return;
}
const psk = await showPromptDialog(this, {
title: this.hass.localize(
"ui.panel.config.matter.panel.prompts.passcode.title"
),
inputLabel: this.hass.localize(
"ui.panel.config.matter.panel.prompts.passcode.input_label"
),
inputType: "password",
confirmText: this.hass.localize(
"ui.panel.config.matter.panel.prompts.passcode.confirm"
),
});
if (!psk) {
return;
}
try {
await matterSetWifi(this.hass, networkName, psk);
} catch (err: any) {
this._error = err.message;
}
}
private async _setThread(): Promise<void> {
const code = await showPromptDialog(this, {
title: this.hass.localize(
"ui.panel.config.matter.panel.prompts.set_thread.title"
),
inputLabel: this.hass.localize(
"ui.panel.config.matter.panel.prompts.set_thread.input_label"
),
inputType: "string",
confirmText: this.hass.localize(
"ui.panel.config.matter.panel.prompts.set_thread.confirm"
),
});
if (!code) {
return;
}
this._error = undefined;
try {
await matterSetThread(this.hass, code);
} catch (err: any) {
this._error = err.message;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.container {
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
}
ha-card {
max-width: 600px;
margin: auto;
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-item-overflow: visible;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"matter-options-page": MatterOptionsPage;
}
}

View File

@@ -75,7 +75,12 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
const networks = this._groupRoutersByNetwork(this._routers, this._datasets);
return html`
<hass-subpage .narrow=${this.narrow} .hass=${this.hass} header="Thread">
<hass-subpage
.narrow=${this.narrow}
.hass=${this.hass}
header="Thread"
back-path="/config"
>
<ha-dropdown slot="toolbar-icon">
<ha-icon-button
.path=${mdiDotsVertical}
@@ -219,7 +224,6 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
slot="graphic"
.src=${brandsUrl({
domain: router.brand,
brand: true,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}

View File

@@ -10,6 +10,7 @@ import "../../../../../components/ha-dialog";
import "../../../../../components/ha-select";
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
import { changeZHANetworkChannel } from "../../../../../data/zha";
import type { HassDialog } from "../../../../../dialogs/make-dialog-manager";
import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../../../types";
import type { ZHAChangeChannelDialogParams } from "./show-dialog-zha-change-channel";
@@ -35,7 +36,10 @@ const VALID_CHANNELS = [
];
@customElement("dialog-zha-change-channel")
class DialogZHAChangeChannel extends LitElement {
class DialogZHAChangeChannel
extends LitElement
implements HassDialog<ZHAChangeChannelDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _migrationInProgress = false;
@@ -46,19 +50,24 @@ class DialogZHAChangeChannel extends LitElement {
@state() private _open = false;
public async showDialog(params: ZHAChangeChannelDialogParams): Promise<void> {
public showDialog(params: ZHAChangeChannelDialogParams): void {
this._params = params;
this._newChannel = "auto";
this._open = true;
}
public closeDialog() {
public closeDialog(): boolean {
if (this._migrationInProgress) {
return false;
}
this._open = false;
return true;
}
private _dialogClosed() {
private _dialogClosed(): void {
this._params = undefined;
this._newChannel = undefined;
this._migrationInProgress = false;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -77,7 +86,12 @@ class DialogZHAChangeChannel extends LitElement {
prevent-scrim-close
@closed=${this._dialogClosed}
>
<ha-alert alert-type="warning">
<ha-alert
alert-type="warning"
.title=${this.hass.localize(
"ui.panel.config.zha.change_channel_dialog.migration_warning_title"
)}
>
${this.hass.localize(
"ui.panel.config.zha.change_channel_dialog.migration_warning"
)}
@@ -95,25 +109,25 @@ class DialogZHAChangeChannel extends LitElement {
)}
</p>
<p>
<ha-select
.label=${this.hass.localize(
"ui.panel.config.zha.change_channel_dialog.new_channel"
)}
@selected=${this._newChannelChosen}
.value=${String(this._newChannel)}
.options=${VALID_CHANNELS.map((channel) => ({
value: String(channel),
label:
channel === "auto"
? this.hass.localize(
"ui.panel.config.zha.change_channel_dialog.channel_auto"
)
: String(channel),
}))}
>
</ha-select>
</p>
<ha-select
.label=${this.hass.localize(
"ui.panel.config.zha.change_channel_dialog.new_channel"
)}
autofocus
@selected=${this._newChannelChosen}
.value=${String(this._newChannel)}
.options=${VALID_CHANNELS.map((channel) => ({
value: String(channel),
label:
channel === "auto"
? this.hass.localize(
"ui.panel.config.zha.change_channel_dialog.channel_auto"
)
: String(channel),
}))}
>
</ha-select>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"

View File

@@ -6,11 +6,10 @@ import "../../../../../components/ha-spinner";
import "../../../../../components/ha-textarea";
import type { ZHADevice } from "../../../../../data/zha";
import { DEVICE_MESSAGE_TYPES, LOG_OUTPUT } from "../../../../../data/zha";
import "../../../../../layouts/hass-tabs-subpage";
import "../../../../../layouts/hass-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import { documentationUrl } from "../../../../../util/documentation-url";
import { zhaTabs } from "./zha-config-dashboard";
import "./zha-device-pairing-status-card";
@customElement("zha-add-devices-page")
@@ -74,11 +73,10 @@ class ZHAAddDevicesPage extends LitElement {
protected render(): TemplateResult {
return html`
<hass-tabs-subpage
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route!}
.tabs=${zhaTabs}
.header=${this.hass.localize("ui.panel.config.zha.add_device")}
>
<ha-button
appearance="plain"
@@ -168,7 +166,7 @@ class ZHAAddDevicesPage extends LitElement {
>
</ha-textarea>`
: ""}
</hass-tabs-subpage>
</hass-subpage>
`;
}

View File

@@ -39,6 +39,18 @@ class ZHAConfigDashboardRouter extends HassRouterPage {
tag: "zha-network-visualization-page",
load: () => import("./zha-network-visualization-page"),
},
options: {
tag: "zha-options-page",
load: () => import("./zha-options-page"),
},
"network-info": {
tag: "zha-network-info-page",
load: () => import("./zha-network-info-page"),
},
section: {
tag: "zha-config-section-page",
load: () => import("./zha-config-section-page"),
},
},
};
@@ -53,6 +65,8 @@ class ZHAConfigDashboardRouter extends HassRouterPage {
el.ieee = this.routeTail.path.substr(1);
} else if (this._currentPage === "visualization") {
el.zoomedDeviceIdFromURL = this.routeTail.path.substr(1);
} else if (this._currentPage === "section") {
el.sectionId = this.routeTail.path.substr(1);
}
}
}

View File

@@ -1,24 +1,25 @@
import {
mdiAlertCircle,
mdiCheckCircle,
mdiAlertCircleOutline,
mdiCheck,
mdiDevices,
mdiDownload,
mdiFolderMultipleOutline,
mdiLan,
mdiNetwork,
mdiPencil,
mdiInformationOutline,
mdiPlus,
mdiShape,
mdiTune,
mdiVectorPolyline,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../components/buttons/ha-progress-button";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-fab";
import "../../../../../components/ha-form/ha-form";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-settings-row";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon";
import type { ConfigEntry } from "../../../../../data/config_entries";
import { getConfigEntries } from "../../../../../data/config_entries";
@@ -30,40 +31,16 @@ import type {
import {
createZHANetworkBackup,
fetchDevices,
fetchGroups,
fetchZHAConfiguration,
fetchZHANetworkSettings,
updateZHAConfiguration,
} from "../../../../../data/zha";
import { showOptionsFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-options-flow";
import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box";
import "../../../../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import "../../../../../layouts/hass-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import { fileDownload } from "../../../../../util/file_download";
import "../../../ha-config-section";
import { showZHAChangeChannelDialog } from "./show-dialog-zha-change-channel";
import type { HaProgressButton } from "../../../../../components/buttons/ha-progress-button";
const MULTIPROTOCOL_ADDON_URL = "socket://core-silabs-multiprotocol:9999";
export const zhaTabs: PageNavigation[] = [
{
translationKey: "ui.panel.config.zha.network.caption",
path: `/config/zha/dashboard`,
iconPath: mdiNetwork,
},
{
translationKey: "ui.panel.config.zha.groups.caption",
path: `/config/zha/groups`,
iconPath: mdiFolderMultipleOutline,
},
{
translationKey: "ui.panel.config.zha.visualization.caption",
path: `/config/zha/visualization`,
iconPath: mdiLan,
},
];
@customElement("zha-config-dashboard")
class ZHAConfigDashboard extends LitElement {
@@ -79,15 +56,15 @@ class ZHAConfigDashboard extends LitElement {
@state() private _configuration?: ZHAConfiguration;
@state() private _networkSettings?: ZHANetworkSettings;
@state() private _totalDevices = 0;
@state() private _offlineDevices = 0;
@state() private _error?: string;
@state() private _totalGroups = 0;
@state() private _generatingBackup = false;
@state() private _networkSettings?: ZHANetworkSettings;
@state() private _error?: string;
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
@@ -95,8 +72,9 @@ class ZHAConfigDashboard extends LitElement {
this.hass.loadBackendTranslation("config_panel", "zha", false);
this._fetchConfigEntry();
this._fetchConfiguration();
this._fetchSettings();
this._fetchDevicesAndUpdateStatus();
this._fetchGroups();
this._fetchNetworkSettings();
}
}
@@ -104,204 +82,17 @@ class ZHAConfigDashboard extends LitElement {
const deviceOnline =
this._offlineDevices < this._totalDevices || this._totalDevices === 0;
return html`
<hass-tabs-subpage
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
.tabs=${zhaTabs}
.header=${this.hass.localize("ui.panel.config.zha.network.caption")}
back-path="/config"
has-fab
>
<div class="container">
<ha-card class="content network-status">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<div class="card-content">
<div class="heading">
<div class="icon">
<ha-svg-icon
.path=${deviceOnline ? mdiCheckCircle : mdiAlertCircle}
class=${deviceOnline ? "online" : "offline"}
></ha-svg-icon>
</div>
<div class="details">
ZHA
${this.hass.localize(
"ui.panel.config.zha.configuration_page.status_title"
)}:
${this.hass.localize(
`ui.panel.config.zha.configuration_page.status_${deviceOnline ? "online" : "offline"}`
)}<br />
<small>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.devices",
{ count: this._totalDevices }
)}
</small>
<small class="offline">
${this._offlineDevices > 0
? html`(${this.hass.localize(
"ui.panel.config.zha.configuration_page.devices_offline",
{ count: this._offlineDevices }
)})`
: nothing}
</small>
</div>
</div>
</div>
<div class="card-actions">
<ha-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
appearance="plain"
size="small"
>
${this.hass.localize(
"ui.panel.config.devices.caption"
)}</ha-button
>
<ha-button
appearance="plain"
size="small"
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
${this.hass.localize(
"ui.panel.config.entities.caption"
)}</ha-button
>
</div>
</ha-card>
<ha-card
class="network-settings"
header=${this.hass.localize(
"ui.panel.config.zha.configuration_page.network_settings_title"
)}
>
${this._networkSettings
? html`<div class="card-content">
<ha-settings-row>
<span slot="description">PAN ID</span>
<span slot="heading"
>${this._networkSettings.settings.network_info
.pan_id}</span
>
</ha-settings-row>
<ha-settings-row>
<span slot="heading"
>${this._networkSettings.settings.network_info
.extended_pan_id}</span
>
<span slot="description">Extended PAN ID</span>
</ha-settings-row>
<ha-settings-row>
<span slot="description">Channel</span>
<span slot="heading"
>${this._networkSettings.settings.network_info
.channel}</span
>
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.zha.configuration_page.change_channel"
)}
.path=${mdiPencil}
@click=${this._showChannelMigrationDialog}
>
</ha-icon-button>
</ha-settings-row>
<ha-settings-row>
<span slot="description">Coordinator IEEE</span>
<span slot="heading"
>${this._networkSettings.settings.node_info.ieee}</span
>
</ha-settings-row>
<ha-settings-row>
<span slot="description">Radio type</span>
<span slot="heading"
>${this._networkSettings.radio_type}</span
>
</ha-settings-row>
<ha-settings-row>
<span slot="description">Serial port</span>
<span slot="heading"
>${this._networkSettings.device.path}</span
>
</ha-settings-row>
${this._networkSettings.device.baudrate &&
!this._networkSettings.device.path.startsWith("socket://")
? html`
<ha-settings-row>
<span slot="description">Baudrate</span>
<span slot="heading"
>${this._networkSettings.device.baudrate}</span
>
</ha-settings-row>
`
: nothing}
</div>`
: nothing}
<div class="card-actions">
<ha-progress-button
appearance="plain"
@click=${this._createAndDownloadBackup}
.progress=${this._generatingBackup}
.disabled=${!this._networkSettings || this._generatingBackup}
>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.download_backup"
)}
</ha-progress-button>
<ha-button
appearance="filled"
variant="brand"
@click=${this._openOptionFlow}
>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.migrate_radio"
)}
</ha-button>
</div>
</ha-card>
${this._configuration
? Object.entries(this._configuration.schemas).map(
([section, schema]) =>
html`<ha-card
header=${this.hass.localize(
`component.zha.config_panel.${section}.title`
)}
>
<div class="card-content">
<ha-form
.hass=${this.hass}
.schema=${schema}
.data=${this._configuration!.data[section]}
@value-changed=${this._dataChanged}
.section=${section}
.computeLabel=${this._computeLabelCallback(
this.hass.localize,
section
)}
></ha-form>
</div>
<div class="card-actions">
<ha-progress-button
appearance="filled"
variant="brand"
@click=${this._updateConfiguration}
>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.update_button"
)}
</ha-progress-button>
</div>
</ha-card>`
)
: nothing}
${this._renderNetworkStatus(deviceOnline)}
${this._renderMyNetworkCard()} ${this._renderNavigationCard()}
${this._renderBackupCard()}
</div>
<a href="/config/zha/add" slot="fab">
@@ -312,7 +103,240 @@ class ZHAConfigDashboard extends LitElement {
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
</a>
</hass-tabs-subpage>
</hass-subpage>
`;
}
private _renderNetworkStatus(deviceOnline: boolean) {
return html`
<ha-card class="content network-status">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<div class="card-content">
<div class="heading">
<div class="icon ${deviceOnline ? "success" : "error"}">
<ha-svg-icon
.path=${deviceOnline ? mdiCheck : mdiAlertCircleOutline}
></ha-svg-icon>
</div>
<div class="details">
${this.hass.localize(
`ui.panel.config.zha.configuration_page.status_${deviceOnline ? "online" : "offline"}`
)}<br />
<small>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.devices",
{ count: this._totalDevices }
)}
</small>
<small class="offline">
${this._offlineDevices > 0
? html`(${this.hass.localize(
"ui.panel.config.zha.configuration_page.devices_offline",
{ count: this._offlineDevices }
)})`
: nothing}
</small>
</div>
</div>
</div>
</ha-card>
`;
}
private _renderMyNetworkCard() {
const deviceIds = this._configEntry
? new Set(
Object.values(this.hass.devices)
.filter((device) =>
device.config_entries.includes(this._configEntry!.entry_id)
)
.map((device) => device.id)
)
: new Set<string>();
const entityCount = Object.values(this.hass.entities).filter(
(entity) => entity.device_id && deviceIds.has(entity.device_id)
).length;
return html`
<ha-card class="nav-card">
<div class="card-header">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.my_network_title"
)}
<ha-button appearance="filled" href="/config/zha/visualization">
<ha-svg-icon slot="start" .path=${mdiVectorPolyline}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.show_map"
)}
</ha-button>
</div>
<div class="card-content">
<ha-md-list>
<ha-md-list-item
type="link"
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.device_count",
{ count: deviceIds.size }
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item
type="link"
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.entity_count",
{ count: entityCount }
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item type="link" href="/config/zha/groups">
<ha-svg-icon
slot="start"
.path=${mdiFolderMultipleOutline}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.group_count",
{ count: this._totalGroups }
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</div>
</ha-card>
`;
}
private _renderNavigationCard() {
const dynamicSections = this._configuration
? Object.keys(this._configuration.schemas).filter(
(section) => section !== "zha_options"
)
: [];
return html`
<ha-card class="nav-card">
<div class="card-content">
<ha-md-list>
<ha-md-list-item type="link" href="/config/zha/options">
<ha-svg-icon slot="start" .path=${mdiTune}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.options_title"
)}
</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.options_description"
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item type="link" href="/config/zha/network-info">
<ha-svg-icon
slot="start"
.path=${mdiInformationOutline}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.network_info_title"
)}
</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.network_info_description"
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
${dynamicSections.map(
(section) => html`
<ha-md-list-item
type="link"
href=${`/config/zha/section/${section}`}
>
<ha-svg-icon slot="start" .path=${mdiTune}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
`component.zha.config_panel.${section}.title`
) || section}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
`
)}
</ha-md-list>
</div>
</ha-card>
`;
}
private _renderBackupCard() {
return html`
<ha-card class="nav-card">
<div class="card-content">
<ha-md-list>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.download_backup"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.download_backup_description"
)}
</span>
<ha-button
appearance="plain"
slot="end"
size="small"
@click=${this._createAndDownloadBackup}
.disabled=${!this._networkSettings}
>
<ha-svg-icon .path=${mdiDownload} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.download_backup_action"
)}
</ha-button>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.migrate_radio"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.zha.configuration_page.migrate_radio_description"
)}
</span>
<ha-button
appearance="plain"
slot="end"
size="small"
@click=${this._openOptionFlow}
>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.migrate_radio_action"
)}
</ha-button>
</ha-md-list-item>
</ha-md-list>
</div>
</ha-card>
`;
}
@@ -329,45 +353,13 @@ class ZHAConfigDashboard extends LitElement {
this._configuration = await fetchZHAConfiguration(this.hass!);
}
private async _fetchSettings(): Promise<void> {
private async _fetchNetworkSettings(): Promise<void> {
this._networkSettings = await fetchZHANetworkSettings(this.hass!);
}
private async _fetchDevicesAndUpdateStatus(): Promise<void> {
try {
const devices = await fetchDevices(this.hass);
this._totalDevices = devices.length;
this._offlineDevices =
this._totalDevices - devices.filter((d) => d.available).length;
} catch (err: any) {
this._error = err.message || err;
}
}
private async _showChannelMigrationDialog(): Promise<void> {
if (this._networkSettings!.device.path === MULTIPROTOCOL_ADDON_URL) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.zha.configuration_page.channel_dialog.title"
),
text: this.hass.localize(
"ui.panel.config.zha.configuration_page.channel_dialog.text"
),
warning: true,
});
return;
}
showZHAChangeChannelDialog(this, {
currentChannel: this._networkSettings!.settings.network_info.channel,
});
}
private async _createAndDownloadBackup(): Promise<void> {
let backup_and_metadata: ZHANetworkBackupAndMetadata;
this._generatingBackup = true;
try {
backup_and_metadata = await createZHANetworkBackup(this.hass!);
} catch (err: any) {
@@ -377,8 +369,6 @@ class ZHAConfigDashboard extends LitElement {
warning: true,
});
return;
} finally {
this._generatingBackup = false;
}
if (!backup_and_metadata.is_complete) {
@@ -410,28 +400,24 @@ class ZHAConfigDashboard extends LitElement {
showOptionsFlowDialog(this, this._configEntry);
}
private _dataChanged(ev) {
this._configuration!.data[ev.currentTarget!.section] = ev.detail.value;
}
private async _updateConfiguration(ev): Promise<any> {
const button = ev.currentTarget as HaProgressButton;
button.progress = true;
private async _fetchGroups(): Promise<void> {
try {
await updateZHAConfiguration(this.hass!, this._configuration!.data);
button.actionSuccess();
} catch (_err: any) {
button.actionError();
} finally {
button.progress = false;
const groups = await fetchGroups(this.hass);
this._totalGroups = groups.length;
} catch (_err) {
// Groups are optional
}
}
private _computeLabelCallback(localize, section: string) {
// Returns a callback for ha-form to calculate labels per schema object
return (schema) =>
localize(`component.zha.config_panel.${section}.${schema.name}`) ||
schema.name;
private async _fetchDevicesAndUpdateStatus(): Promise<void> {
try {
const devices = await fetchDevices(this.hass);
this._totalDevices = devices.length;
this._offlineDevices =
this._totalDevices - devices.filter((d) => d.available).length;
} catch (err: any) {
this._error = err.message || err;
}
}
static get styles(): CSSResultGroup {
@@ -440,75 +426,102 @@ class ZHAConfigDashboard extends LitElement {
css`
ha-card {
margin: auto;
margin-top: 16px;
max-width: 500px;
margin-top: var(--ha-space-4);
max-width: 600px;
}
ha-card .card-actions {
.nav-card .card-header {
display: flex;
justify-content: flex-end;
align-items: center;
justify-content: space-between;
padding-bottom: var(--ha-space-2);
}
.network-settings ha-settings-row {
padding-left: 0;
padding-right: 0;
padding-inline-start: 0;
padding-inline-end: 0;
.nav-card {
overflow: hidden;
}
.network-settings ha-settings-row span[slot="heading"] {
white-space: normal;
word-break: break-all;
text-indent: -1em;
padding-left: 1em;
padding-inline-start: 1em;
padding-inline-end: initial;
}
.network-settings ha-settings-row ha-icon-button {
margin-top: -16px;
margin-bottom: -16px;
.nav-card .card-content {
padding: 0;
}
.content {
margin-top: 24px;
margin-top: var(--ha-space-6);
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-item-overflow: visible;
}
ha-button[size="small"] ha-svg-icon {
--mdc-icon-size: 16px;
}
.network-status div.heading {
display: flex;
align-items: center;
column-gap: var(--ha-space-4);
}
.network-status div.heading .icon {
margin-inline-end: 16px;
position: relative;
border-radius: var(--ha-border-radius-2xl);
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
--icon-color: var(--primary-color);
}
.network-status div.heading ha-svg-icon {
--mdc-icon-size: 48px;
.network-status div.heading .icon.success {
--icon-color: var(--success-color);
}
.network-status div.heading .icon.error {
--icon-color: var(--error-color);
}
.network-status div.heading .icon::before {
display: block;
content: "";
position: absolute;
inset: 0;
background-color: var(--icon-color);
opacity: 0.2;
}
.network-status div.heading .icon ha-svg-icon {
color: var(--icon-color);
width: 24px;
height: 24px;
}
.network-status div.heading .details {
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
color: var(--primary-text-color);
}
.network-status small {
font-size: var(--ha-font-size-m);
}
.network-status small.offline {
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
letter-spacing: 0.25px;
color: var(--secondary-text-color);
}
.network-status .online {
color: var(--state-on-color, var(--success-color));
}
.network-status .offline {
color: var(--error-color, var(--error-color));
}
.container {
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
padding: var(--ha-space-2) var(--ha-space-4)
calc(var(--ha-space-20) + var(--safe-area-inset-bottom, 0px));
}
`,
];

View File

@@ -0,0 +1,143 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../components/buttons/ha-progress-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-form/ha-form";
import type { ZHAConfiguration } from "../../../../../data/zha";
import {
fetchZHAConfiguration,
updateZHAConfiguration,
} from "../../../../../data/zha";
import "../../../../../layouts/hass-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
@customElement("zha-config-section-page")
class ZHAConfigSectionPage extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public route!: Route;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: "section-id" }) public sectionId!: string;
@state() private _configuration?: ZHAConfiguration;
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
if (this.hass) {
this.hass.loadBackendTranslation("config_panel", "zha", false);
this._fetchConfiguration();
}
}
private async _fetchConfiguration(): Promise<void> {
this._configuration = await fetchZHAConfiguration(this.hass!);
}
protected render(): TemplateResult {
const schema = this._configuration?.schemas[this.sectionId];
const data = this._configuration?.data[this.sectionId];
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
`component.zha.config_panel.${this.sectionId}.title`
) || this.sectionId}
back-path="/config/zha/dashboard"
>
<div class="container">
<ha-card>
${schema && data
? html`
<div class="card-content">
<ha-form
.hass=${this.hass}
.schema=${schema}
.data=${data}
@value-changed=${this._dataChanged}
.computeLabel=${this._computeLabelCallback(
this.hass.localize,
this.sectionId
)}
></ha-form>
</div>
<div class="card-actions">
<ha-progress-button
appearance="filled"
variant="brand"
@click=${this._updateConfiguration}
>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.update_button"
)}
</ha-progress-button>
</div>
`
: nothing}
</ha-card>
</div>
</hass-subpage>
`;
}
private _dataChanged(ev) {
this._configuration!.data[this.sectionId] = ev.detail.value;
}
private async _updateConfiguration(ev: Event): Promise<void> {
const button = ev.currentTarget as HTMLElement & {
progress: boolean;
actionSuccess: () => void;
actionError: () => void;
};
button.progress = true;
try {
await updateZHAConfiguration(this.hass!, this._configuration!.data);
button.actionSuccess();
} catch (_err: any) {
button.actionError();
} finally {
button.progress = false;
}
}
private _computeLabelCallback(localize, section: string) {
return (schema) =>
localize(`component.zha.config_panel.${section}.${schema.name}`) ||
schema.name;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.container {
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
}
ha-card {
max-width: 600px;
margin: auto;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"zha-config-section-page": ZHAConfigSectionPage;
}
}

View File

@@ -1,4 +1,4 @@
import { mdiPlus } from "@mdi/js";
import { mdiFolderMultipleOutline, mdiPlus } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -15,10 +15,18 @@ import "../../../../../components/ha-icon-button";
import type { ZHAGroup } from "../../../../../data/zha";
import { fetchGroups } from "../../../../../data/zha";
import "../../../../../layouts/hass-tabs-subpage-data-table";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import { formatAsPaddedHex, sortZHAGroups } from "./functions";
import { zhaTabs } from "./zha-config-dashboard";
const groupsTab: PageNavigation[] = [
{
translationKey: "ui.panel.config.zha.groups.caption",
path: "/config/zha/groups",
iconPath: mdiFolderMultipleOutline,
},
];
export interface GroupRowData extends ZHAGroup {
group?: GroupRowData;
@@ -100,7 +108,8 @@ export class ZHAGroupsDashboard extends LitElement {
protected render(): TemplateResult {
return html`
<hass-tabs-subpage-data-table
.tabs=${zhaTabs}
.tabs=${groupsTab}
back-path="/config/zha/dashboard"
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}

View File

@@ -0,0 +1,226 @@
import { getDeviceContext } from "../../../../../common/entity/context/get_device_context";
import type {
NetworkData,
NetworkLink,
NetworkNode,
} from "../../../../../components/chart/ha-network-graph";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import type { ZHADevice } from "../../../../../data/zha";
import type { HomeAssistant } from "../../../../../types";
function getLQIWidth(lqi: number): number {
return lqi > 200 ? 3 : lqi > 100 ? 2 : 1;
}
export function createZHANetworkChartData(
devices: ZHADevice[],
hass: HomeAssistant,
element: Element
): NetworkData {
const style = getComputedStyle(element);
const primaryColor = style.getPropertyValue("--primary-color");
const routerColor = style.getPropertyValue("--cyan-color");
const endDeviceColor = style.getPropertyValue("--teal-color");
const offlineColor = style.getPropertyValue("--error-color");
const nodes: NetworkNode[] = [];
const links: NetworkLink[] = [];
const categories = [
{
name: hass.localize("ui.panel.config.zha.visualization.coordinator"),
symbol: "roundRect",
itemStyle: { color: primaryColor },
},
{
name: hass.localize("ui.panel.config.zha.visualization.router"),
symbol: "circle",
itemStyle: { color: routerColor },
},
{
name: hass.localize("ui.panel.config.zha.visualization.end_device"),
symbol: "circle",
itemStyle: { color: endDeviceColor },
},
{
name: hass.localize("ui.panel.config.zha.visualization.offline"),
symbol: "circle",
itemStyle: { color: offlineColor },
},
];
// Create all the nodes and links
devices.forEach((device) => {
const isCoordinator = device.device_type === "Coordinator";
let category: number;
if (!device.available) {
category = 3; // Offline
} else if (isCoordinator) {
category = 0;
} else if (device.device_type === "Router") {
category = 1;
} else {
category = 2; // End Device
}
const haDevice = hass.devices[device.device_reg_id] as
| DeviceRegistryEntry
| undefined;
const area = haDevice ? getDeviceContext(haDevice, hass).area : undefined;
// Create node
nodes.push({
id: device.ieee,
name: device.user_given_name || device.name || device.ieee,
context: area?.name,
category,
value: isCoordinator ? 3 : device.device_type === "Router" ? 2 : 1,
symbolSize: isCoordinator
? 40
: device.device_type === "Router"
? 30
: 20,
symbol: isCoordinator ? "roundRect" : "circle",
itemStyle: {
color: device.available
? isCoordinator
? primaryColor
: device.device_type === "Router"
? routerColor
: endDeviceColor
: offlineColor,
},
polarDistance: category === 0 ? 0 : category === 1 ? 0.5 : 0.9,
fixed: isCoordinator,
});
// Create links (edges)
const existingLinks = links.filter(
(link) => link.source === device.ieee || link.target === device.ieee
);
if (device.routes && device.routes.length > 0) {
device.routes.forEach((route) => {
const neighbor = device.neighbors.find((n) => n.nwk === route.next_hop);
if (!neighbor) {
return;
}
const existingLink = existingLinks.find(
(link) =>
link.source === neighbor.ieee || link.target === neighbor.ieee
);
if (existingLink) {
if (existingLink.source === device.ieee) {
existingLink.value = Math.max(
existingLink.value!,
parseInt(neighbor.lqi)
);
} else {
existingLink.reverseValue = Math.max(
existingLink.reverseValue ?? 0,
parseInt(neighbor.lqi)
);
}
const width = getLQIWidth(parseInt(neighbor.lqi));
existingLink.symbolSize = (width / 4) * 6 + 3; // range 3-9
existingLink.lineStyle = {
...existingLink.lineStyle,
width,
color:
route.route_status === "Active"
? primaryColor
: existingLink.lineStyle!.color,
type: ["Child", "Parent"].includes(neighbor.relationship)
? "solid"
: existingLink.lineStyle!.type,
};
} else {
// Create a new link
const width = getLQIWidth(parseInt(neighbor.lqi));
const link: NetworkLink = {
source: device.ieee,
target: neighbor.ieee,
value: parseInt(neighbor.lqi),
lineStyle: {
width,
color:
route.route_status === "Active"
? primaryColor
: style.getPropertyValue("--dark-primary-color"),
type: ["Child", "Parent"].includes(neighbor.relationship)
? "solid"
: "dotted",
},
symbolSize: (width / 4) * 6 + 3, // range 3-9
// By default, all links should be ignored for force layout
// unless it's a route to the coordinator
ignoreForceLayout: route.dest_nwk !== "0x0000",
};
links.push(link);
existingLinks.push(link);
}
});
} else if (existingLinks.length === 0) {
// If there are no links, create a link to the closest neighbor
const neighbors: { ieee: string; lqi: string }[] = device.neighbors ?? [];
if (neighbors.length === 0) {
// If there are no neighbors, look for links from other devices
devices.forEach((d) => {
if (d.neighbors && d.neighbors.length > 0) {
const neighbor = d.neighbors.find((n) => n.ieee === device.ieee);
if (neighbor) {
neighbors.push({ ieee: d.ieee, lqi: neighbor.lqi });
}
}
});
}
const closestNeighbor = neighbors.sort(
(a, b) => parseInt(b.lqi) - parseInt(a.lqi)
)[0];
if (closestNeighbor) {
links.push({
source: device.ieee,
target: closestNeighbor.ieee,
value: parseInt(closestNeighbor.lqi),
symbolSize: 5,
lineStyle: {
width: 1,
color: style.getPropertyValue("--dark-primary-color"),
type: "dotted",
},
ignoreForceLayout: true,
});
}
}
});
// Now set ignoreForceLayout to false for the best connection of each device
// Except for the coordinator which can have multiple strong connections
devices.forEach((device) => {
if (device.device_type === "Coordinator") {
links.forEach((link) => {
if (link.source === device.ieee || link.target === device.ieee) {
link.ignoreForceLayout = false;
}
});
} else {
// Find the link that corresponds to this strongest connection
let bestLink: NetworkLink | undefined;
const alreadyHasBestLink = links.some((link) => {
if (link.source === device.ieee || link.target === device.ieee) {
if (!link.ignoreForceLayout) {
return true;
}
if (link.value! > (bestLink?.value ?? -1)) {
bestLink = link;
}
}
return false;
});
if (!alreadyHasBestLink && bestLink) {
bestLink.ignoreForceLayout = false;
}
}
});
return { nodes, links, categories };
}

View File

@@ -0,0 +1,191 @@
import { mdiPencil } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import type { ZHANetworkSettings } from "../../../../../data/zha";
import { fetchZHANetworkSettings } from "../../../../../data/zha";
import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box";
import "../../../../../layouts/hass-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import { showZHAChangeChannelDialog } from "./show-dialog-zha-change-channel";
const MULTIPROTOCOL_ADDON_URL = "socket://core-silabs-multiprotocol:9999";
@customElement("zha-network-info-page")
class ZHANetworkInfoPage extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public route!: Route;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@state() private _networkSettings?: ZHANetworkSettings;
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
if (this.hass) {
this._fetchSettings();
}
}
private async _fetchSettings(): Promise<void> {
this._networkSettings = await fetchZHANetworkSettings(this.hass!);
}
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
"ui.panel.config.zha.configuration_page.network_info_title"
)}
back-path="/config/zha/dashboard"
>
<div class="container">
<ha-card>
${this._networkSettings
? html`<ha-md-list>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.channel_label"
)}</span
>
<span slot="supporting-text"
>${this._networkSettings.settings.network_info
.channel}</span
>
<ha-icon-button
slot="end"
.label=${this.hass.localize(
"ui.panel.config.zha.configuration_page.change_channel"
)}
.path=${mdiPencil}
@click=${this._showChannelMigrationDialog}
></ha-icon-button>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">PAN ID</span>
<span slot="supporting-text"
>${this._networkSettings.settings.network_info
.pan_id}</span
>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">Extended PAN ID</span>
<span slot="supporting-text"
>${this._networkSettings.settings.network_info
.extended_pan_id}</span
>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">Coordinator IEEE</span>
<span slot="supporting-text"
>${this._networkSettings.settings.node_info.ieee}</span
>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.radio_type"
)}</span
>
<span slot="supporting-text"
>${this._networkSettings.radio_type}</span
>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.serial_port"
)}</span
>
<span slot="supporting-text"
>${this._networkSettings.device.path}</span
>
</ha-md-list-item>
${this._networkSettings.device.baudrate &&
!this._networkSettings.device.path.startsWith("socket://")
? html`
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.baudrate"
)}</span
>
<span slot="supporting-text"
>${this._networkSettings.device.baudrate}</span
>
</ha-md-list-item>
`
: nothing}
</ha-md-list>`
: nothing}
</ha-card>
</div>
</hass-subpage>
`;
}
private async _showChannelMigrationDialog(): Promise<void> {
if (this._networkSettings!.device.path === MULTIPROTOCOL_ADDON_URL) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.zha.configuration_page.channel_dialog.title"
),
text: this.hass.localize(
"ui.panel.config.zha.configuration_page.channel_dialog.text"
),
warning: true,
});
return;
}
showZHAChangeChannelDialog(this, {
currentChannel: this._networkSettings!.settings.network_info.channel,
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.container {
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
}
ha-card {
max-width: 600px;
margin: auto;
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-item-overflow: visible;
--md-list-item-supporting-text-size: var(
--md-list-item-label-text-size,
var(--md-sys-typescale-body-large-size, 1rem)
);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"zha-network-info-page": ZHANetworkInfoPage;
}
}

View File

@@ -9,18 +9,14 @@ import { customElement, property, state } from "lit/decorators";
import { getDeviceContext } from "../../../../../common/entity/context/get_device_context";
import { navigate } from "../../../../../common/navigate";
import "../../../../../components/chart/ha-network-graph";
import type {
NetworkData,
NetworkLink,
NetworkNode,
} from "../../../../../components/chart/ha-network-graph";
import type { NetworkData } from "../../../../../components/chart/ha-network-graph";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import type { ZHADevice } from "../../../../../data/zha";
import { fetchDevices, refreshTopology } from "../../../../../data/zha";
import "../../../../../layouts/hass-tabs-subpage";
import "../../../../../layouts/hass-subpage";
import type { HomeAssistant, Route } from "../../../../../types";
import { formatAsPaddedHex } from "./functions";
import { zhaTabs } from "./zha-config-dashboard";
import { createZHANetworkChartData } from "./zha-network-data";
@customElement("zha-network-visualization-page")
export class ZHANetworkVisualizationPage extends LitElement {
@@ -52,13 +48,12 @@ export class ZHANetworkVisualizationPage extends LitElement {
protected render() {
return html`
<hass-tabs-subpage
.tabs=${zhaTabs}
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.isWide=${this.isWide}
.route=${this.route}
header=${this.hass.localize("ui.panel.config.zha.visualization.header")}
.header=${this.hass.localize(
"ui.panel.config.zha.visualization.header"
)}
>
<ha-network-graph
.hass=${this.hass}
@@ -76,13 +71,17 @@ export class ZHANetworkVisualizationPage extends LitElement {
)}
></ha-icon-button>
</ha-network-graph>
</hass-tabs-subpage>
</hass-subpage>
`;
}
private async _fetchData() {
this._devices = await fetchDevices(this.hass!);
this._networkData = this._createChartData(this._devices);
this._networkData = createZHANetworkChartData(
this._devices,
this.hass,
this
);
}
private _tooltipFormatter = (params: TopLevelFormatterParams): string => {
@@ -158,228 +157,6 @@ export class ZHANetworkVisualizationPage extends LitElement {
`,
];
}
private _createChartData(devices: ZHADevice[]): NetworkData {
const style = getComputedStyle(this);
const primaryColor = style.getPropertyValue("--primary-color");
const routerColor = style.getPropertyValue("--cyan-color");
const endDeviceColor = style.getPropertyValue("--teal-color");
const offlineColor = style.getPropertyValue("--error-color");
const nodes: NetworkNode[] = [];
const links: NetworkLink[] = [];
const categories = [
{
name: this.hass.localize(
"ui.panel.config.zha.visualization.coordinator"
),
symbol: "roundRect",
itemStyle: { color: primaryColor },
},
{
name: this.hass.localize("ui.panel.config.zha.visualization.router"),
symbol: "circle",
itemStyle: { color: routerColor },
},
{
name: this.hass.localize(
"ui.panel.config.zha.visualization.end_device"
),
symbol: "circle",
itemStyle: { color: endDeviceColor },
},
{
name: this.hass.localize("ui.panel.config.zha.visualization.offline"),
symbol: "circle",
itemStyle: { color: offlineColor },
},
];
// Create all the nodes and links
devices.forEach((device) => {
const isCoordinator = device.device_type === "Coordinator";
let category: number;
if (!device.available) {
category = 3; // Offline
} else if (isCoordinator) {
category = 0;
} else if (device.device_type === "Router") {
category = 1;
} else {
category = 2; // End Device
}
const haDevice = this.hass.devices[device.device_reg_id] as
| DeviceRegistryEntry
| undefined;
const area = haDevice
? getDeviceContext(haDevice, this.hass).area
: undefined;
// Create node
nodes.push({
id: device.ieee,
name: device.user_given_name || device.name || device.ieee,
context: area?.name,
category,
value: isCoordinator ? 3 : device.device_type === "Router" ? 2 : 1,
symbolSize: isCoordinator
? 40
: device.device_type === "Router"
? 30
: 20,
symbol: isCoordinator ? "roundRect" : "circle",
itemStyle: {
color: device.available
? isCoordinator
? primaryColor
: device.device_type === "Router"
? routerColor
: endDeviceColor
: offlineColor,
},
polarDistance: category === 0 ? 0 : category === 1 ? 0.5 : 0.9,
fixed: isCoordinator,
});
// Create links (edges)
const existingLinks = links.filter(
(link) => link.source === device.ieee || link.target === device.ieee
);
if (device.routes && device.routes.length > 0) {
device.routes.forEach((route) => {
const neighbor = device.neighbors.find(
(n) => n.nwk === route.next_hop
);
if (!neighbor) {
return;
}
const existingLink = existingLinks.find(
(link) =>
link.source === neighbor.ieee || link.target === neighbor.ieee
);
if (existingLink) {
if (existingLink.source === device.ieee) {
existingLink.value = Math.max(
existingLink.value!,
parseInt(neighbor.lqi)
);
} else {
existingLink.reverseValue = Math.max(
existingLink.reverseValue ?? 0,
parseInt(neighbor.lqi)
);
}
const width = this._getLQIWidth(parseInt(neighbor.lqi));
existingLink.symbolSize = (width / 4) * 6 + 3; // range 3-9
existingLink.lineStyle = {
...existingLink.lineStyle,
width,
color:
route.route_status === "Active"
? primaryColor
: existingLink.lineStyle!.color,
type: ["Child", "Parent"].includes(neighbor.relationship)
? "solid"
: existingLink.lineStyle!.type,
};
} else {
// Create a new link
const width = this._getLQIWidth(parseInt(neighbor.lqi));
const link: NetworkLink = {
source: device.ieee,
target: neighbor.ieee,
value: parseInt(neighbor.lqi),
lineStyle: {
width,
color:
route.route_status === "Active"
? primaryColor
: style.getPropertyValue("--dark-primary-color"),
type: ["Child", "Parent"].includes(neighbor.relationship)
? "solid"
: "dotted",
},
symbolSize: (width / 4) * 6 + 3, // range 3-9
// By default, all links should be ignored for force layout
// unless it's a route to the coordinator
ignoreForceLayout: route.dest_nwk !== "0x0000",
};
links.push(link);
existingLinks.push(link);
}
});
} else if (existingLinks.length === 0) {
// If there are no links, create a link to the closest neighbor
const neighbors: { ieee: string; lqi: string }[] =
device.neighbors ?? [];
if (neighbors.length === 0) {
// If there are no neighbors, look for links from other devices
devices.forEach((d) => {
if (d.neighbors && d.neighbors.length > 0) {
const neighbor = d.neighbors.find((n) => n.ieee === device.ieee);
if (neighbor) {
neighbors.push({ ieee: d.ieee, lqi: neighbor.lqi });
}
}
});
}
const closestNeighbor = neighbors.sort(
(a, b) => parseInt(b.lqi) - parseInt(a.lqi)
)[0];
if (closestNeighbor) {
links.push({
source: device.ieee,
target: closestNeighbor.ieee,
value: parseInt(closestNeighbor.lqi),
symbolSize: 5,
lineStyle: {
width: 1,
color: style.getPropertyValue("--dark-primary-color"),
type: "dotted",
},
ignoreForceLayout: true,
});
}
}
});
// Now set ignoreForceLayout to false for the best connection of each device
// Except for the coordinator which can have multiple strong connections
devices.forEach((device) => {
if (device.device_type === "Coordinator") {
links.forEach((link) => {
if (link.source === device.ieee || link.target === device.ieee) {
link.ignoreForceLayout = false;
}
});
} else {
// Find the link that corresponds to this strongest connection
let bestLink: NetworkLink | undefined;
const alreadyHasBestLink = links.some((link) => {
if (link.source === device.ieee || link.target === device.ieee) {
if (!link.ignoreForceLayout) {
return true;
}
if (link.value! > (bestLink?.value ?? -1)) {
bestLink = link;
}
}
return false;
});
if (!alreadyHasBestLink && bestLink) {
bestLink.ignoreForceLayout = false;
}
}
});
return { nodes, links, categories };
}
private _getLQIWidth(lqi: number): number {
return lqi > 200 ? 3 : lqi > 100 ? 2 : 1;
}
}
declare global {

View File

@@ -0,0 +1,468 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../components/buttons/ha-progress-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-select";
import "../../../../../components/ha-textfield";
import "../../../../../components/ha-switch";
import type { ZHAConfiguration } from "../../../../../data/zha";
import {
fetchZHAConfiguration,
updateZHAConfiguration,
} from "../../../../../data/zha";
import "../../../../../layouts/hass-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
const PREDEFINED_TIMEOUTS = [1800, 3600, 7200, 21600, 43200, 86400];
@customElement("zha-options-page")
class ZHAOptionsPage extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public route!: Route;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@state() private _configuration?: ZHAConfiguration;
@state() private _customMains = false;
@state() private _customBattery = false;
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
if (this.hass) {
this._fetchConfiguration();
}
}
private async _fetchConfiguration(): Promise<void> {
this._configuration = await fetchZHAConfiguration(this.hass!);
const mainsValue = this._configuration.data.zha_options
?.consider_unavailable_mains as number | undefined;
const batteryValue = this._configuration.data.zha_options
?.consider_unavailable_battery as number | undefined;
this._customMains =
mainsValue !== undefined && !PREDEFINED_TIMEOUTS.includes(mainsValue);
this._customBattery =
batteryValue !== undefined && !PREDEFINED_TIMEOUTS.includes(batteryValue);
}
private _getUnavailableTimeoutOptions(defaultSeconds: number) {
const defaultLabel = ` (${this.hass.localize("ui.panel.config.zha.configuration_page.timeout_default")})`;
const options: { value: string; seconds: number; key: string }[] = [
{ value: "1800", seconds: 1800, key: "timeout_30_min" },
{ value: "3600", seconds: 3600, key: "timeout_1_hour" },
{ value: "7200", seconds: 7200, key: "timeout_2_hours" },
{ value: "21600", seconds: 21600, key: "timeout_6_hours" },
{ value: "43200", seconds: 43200, key: "timeout_12_hours" },
{ value: "86400", seconds: 86400, key: "timeout_24_hours" },
];
return [
...options.map((opt) => ({
value: opt.value,
label: this.hass.localize(
`ui.panel.config.zha.configuration_page.${opt.key}`,
{ default: opt.seconds === defaultSeconds ? defaultLabel : "" }
),
})),
{
value: "custom",
label: this.hass.localize(
"ui.panel.config.zha.configuration_page.timeout_custom"
),
},
];
}
private _getUnavailableDropdownValue(
seconds: unknown,
isCustom: boolean
): string {
if (isCustom) {
return "custom";
}
const value = (seconds as number) ?? 7200;
if (PREDEFINED_TIMEOUTS.includes(value)) {
return String(value);
}
return "custom";
}
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
"ui.panel.config.zha.configuration_page.options_title"
)}
back-path="/config/zha/dashboard"
>
<div class="container">
<ha-card>
${this._configuration
? html`
<ha-md-list>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.enable_identify_on_join_label"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.enable_identify_on_join_description"
)}</span
>
<ha-switch
slot="end"
.checked=${(this._configuration.data.zha_options
?.enable_identify_on_join as boolean) ?? true}
@change=${this._enableIdentifyOnJoinChanged}
></ha-switch>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.default_light_transition_label"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.default_light_transition_description"
)}</span
>
<ha-textfield
slot="end"
type="number"
.value=${String(
(this._configuration.data.zha_options
?.default_light_transition as number) ?? 0
)}
.suffix=${"s"}
.min=${0}
.step=${0.5}
@change=${this._defaultLightTransitionChanged}
></ha-textfield>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.enhanced_light_transition_label"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.enhanced_light_transition_description"
)}</span
>
<ha-switch
slot="end"
.checked=${(this._configuration.data.zha_options
?.enhanced_light_transition as boolean) ?? false}
@change=${this._enhancedLightTransitionChanged}
></ha-switch>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.light_transitioning_flag_label"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.light_transitioning_flag_description"
)}</span
>
<ha-switch
slot="end"
.checked=${(this._configuration.data.zha_options
?.light_transitioning_flag as boolean) ?? true}
@change=${this._lightTransitioningFlagChanged}
></ha-switch>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.group_members_assume_state_label"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.group_members_assume_state_description"
)}</span
>
<ha-switch
slot="end"
.checked=${(this._configuration.data.zha_options
?.group_members_assume_state as boolean) ?? true}
@change=${this._groupMembersAssumeStateChanged}
></ha-switch>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.consider_unavailable_mains_label"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.consider_unavailable_mains_description"
)}</span
>
<ha-select
slot="end"
.value=${this._getUnavailableDropdownValue(
this._configuration.data.zha_options
?.consider_unavailable_mains,
this._customMains
)}
.options=${this._getUnavailableTimeoutOptions(7200)}
@selected=${this._mainsUnavailableChanged}
></ha-select>
</ha-md-list-item>
${this._customMains
? html`
<ha-md-list-item>
<ha-textfield
slot="end"
type="number"
.value=${String(
(this._configuration.data.zha_options
?.consider_unavailable_mains as number) ??
7200
)}
.suffix=${"s"}
.min=${1}
.step=${1}
@change=${this._customMainsSecondsChanged}
></ha-textfield>
</ha-md-list-item>
`
: nothing}
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.consider_unavailable_battery_label"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.consider_unavailable_battery_description"
)}</span
>
<ha-select
slot="end"
.value=${this._getUnavailableDropdownValue(
this._configuration.data.zha_options
?.consider_unavailable_battery,
this._customBattery
)}
.options=${this._getUnavailableTimeoutOptions(21600)}
@selected=${this._batteryUnavailableChanged}
></ha-select>
</ha-md-list-item>
${this._customBattery
? html`
<ha-md-list-item>
<ha-textfield
slot="end"
type="number"
.value=${String(
(this._configuration.data.zha_options
?.consider_unavailable_battery as number) ??
21600
)}
.suffix=${"s"}
.min=${1}
.step=${1}
@change=${this._customBatterySecondsChanged}
></ha-textfield>
</ha-md-list-item>
`
: nothing}
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.enable_mains_startup_polling_label"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.zha.configuration_page.enable_mains_startup_polling_description"
)}</span
>
<ha-switch
slot="end"
.checked=${(this._configuration.data.zha_options
?.enable_mains_startup_polling as boolean) ?? true}
@change=${this._enableMainsStartupPollingChanged}
></ha-switch>
</ha-md-list-item>
</ha-md-list>
<div class="card-actions">
<ha-progress-button
appearance="filled"
variant="brand"
@click=${this._updateConfiguration}
>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.update_button"
)}
</ha-progress-button>
</div>
`
: nothing}
</ha-card>
</div>
</hass-subpage>
`;
}
private _enableIdentifyOnJoinChanged(ev: Event): void {
const checked = (ev.target as HTMLInputElement).checked;
this._configuration!.data.zha_options.enable_identify_on_join = checked;
this.requestUpdate();
}
private _enhancedLightTransitionChanged(ev: Event): void {
const checked = (ev.target as HTMLInputElement).checked;
this._configuration!.data.zha_options.enhanced_light_transition = checked;
this.requestUpdate();
}
private _lightTransitioningFlagChanged(ev: Event): void {
const checked = (ev.target as HTMLInputElement).checked;
this._configuration!.data.zha_options.light_transitioning_flag = checked;
this.requestUpdate();
}
private _groupMembersAssumeStateChanged(ev: Event): void {
const checked = (ev.target as HTMLInputElement).checked;
this._configuration!.data.zha_options.group_members_assume_state = checked;
this.requestUpdate();
}
private _enableMainsStartupPollingChanged(ev: Event): void {
const checked = (ev.target as HTMLInputElement).checked;
this._configuration!.data.zha_options.enable_mains_startup_polling =
checked;
this.requestUpdate();
}
private _defaultLightTransitionChanged(ev: Event): void {
const value = Number((ev.target as HTMLInputElement).value);
this._configuration!.data.zha_options.default_light_transition = value;
this.requestUpdate();
}
private _customMainsSecondsChanged(ev: Event): void {
const seconds = Number((ev.target as HTMLInputElement).value);
this._configuration!.data.zha_options.consider_unavailable_mains = seconds;
this.requestUpdate();
}
private _customBatterySecondsChanged(ev: Event): void {
const seconds = Number((ev.target as HTMLInputElement).value);
this._configuration!.data.zha_options.consider_unavailable_battery =
seconds;
this.requestUpdate();
}
private _mainsUnavailableChanged(ev: CustomEvent): void {
const value = ev.detail.value;
if (value === "custom") {
this._customMains = true;
} else {
this._customMains = false;
this._configuration!.data.zha_options.consider_unavailable_mains =
Number(value);
}
this.requestUpdate();
}
private _batteryUnavailableChanged(ev: CustomEvent): void {
const value = ev.detail.value;
if (value === "custom") {
this._customBattery = true;
} else {
this._customBattery = false;
this._configuration!.data.zha_options.consider_unavailable_battery =
Number(value);
}
this.requestUpdate();
}
private async _updateConfiguration(ev: Event): Promise<void> {
const button = ev.currentTarget as HTMLElement & {
progress: boolean;
actionSuccess: () => void;
actionError: () => void;
};
button.progress = true;
try {
await updateZHAConfiguration(this.hass!, this._configuration!.data);
button.actionSuccess();
} catch (_err: any) {
button.actionError();
} finally {
button.progress = false;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.container {
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
}
ha-card {
max-width: 600px;
margin: auto;
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-item-overflow: visible;
}
ha-select,
ha-textfield {
min-width: 210px;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
@media all and (max-width: 450px) {
ha-select,
ha-textfield {
min-width: 160px;
width: 160px;
}
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"zha-options-page": ZHAOptionsPage;
}
}

View File

@@ -18,6 +18,7 @@ import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { goBack } from "../../../../../common/navigate";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-fab";
@@ -28,7 +29,6 @@ import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-progress-ring";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-svg-icon";
import { goBack } from "../../../../../common/navigate";
import type { ConfigEntry } from "../../../../../data/config_entries";
import {
ERROR_STATES,
@@ -144,6 +144,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
.header=${this.hass.localize(
"ui.panel.config.zwave_js.navigation.general"
)}
back-path="/config"
has-fab
>
<ha-icon-button
@@ -967,7 +968,8 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
}
.container {
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
padding: var(--ha-space-2) var(--ha-space-4)
calc(var(--ha-space-16) + var(--safe-area-inset-bottom, 0px));
}
`,
];

View File

@@ -147,11 +147,10 @@ export class HaConfigLabels extends LitElement {
created_at: getCreatedAtTableColumn(localize, this.hass),
modified_at: getModifiedAtTableColumn(localize, this.hass),
actions: {
lastFixed: true,
title: "",
label: localize("ui.panel.config.generic.headers.actions"),
showNarrow: true,
moveable: false,
hideable: false,
type: "overflow-menu",
template: (label) => html`
<ha-icon-button

View File

@@ -102,11 +102,15 @@ export class DialogLovelaceDashboardDetail extends LitElement {
: html`
<ha-form
autofocus
.schema=${this._schema(this._params)}
.schema=${this._schema(
this._params,
this._data?.require_admin
)}
.data=${this._data}
.hass=${this.hass}
.error=${this._error}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`}
@@ -155,7 +159,7 @@ export class DialogLovelaceDashboardDetail extends LitElement {
}
private _schema = memoizeOne(
(params: LovelaceDashboardDetailsDialogParams) =>
(params: LovelaceDashboardDetailsDialogParams, requireAdmin?: boolean) =>
[
{
name: "title",
@@ -183,6 +187,7 @@ export class DialogLovelaceDashboardDetail extends LitElement {
{
name: "require_admin",
required: true,
disabled: params.isDefault && !requireAdmin,
selector: {
boolean: {},
},
@@ -210,6 +215,15 @@ export class DialogLovelaceDashboardDetail extends LitElement {
}`
);
private _computeHelper = (
entry: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
entry.name === "require_admin" && entry.disabled
? this.hass.localize(
"ui.panel.config.lovelace.dashboards.panel_detail.require_admin_helper"
)
: "";
private _valueChanged(ev: CustomEvent) {
this._error = undefined;
const value = ev.detail.value;

View File

@@ -0,0 +1,247 @@
import { mdiDotsVertical, mdiRestart } from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-form/ha-form";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-dialog";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { PanelMutableParams } from "../../../../data/panel";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { PanelDetailDialogParams } from "./show-dialog-panel-detail";
interface PanelDetailData {
title: string;
icon?: string;
require_admin: boolean;
show_in_sidebar: boolean;
}
@customElement("dialog-panel-detail")
export class DialogPanelDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: PanelDetailDialogParams;
@state() private _data?: PanelDetailData;
@state() private _error?: Record<string, string>;
@state() private _submitting = false;
@state() private _open = false;
public showDialog(params: PanelDetailDialogParams): void {
this._params = params;
this._error = undefined;
this._data = {
title: params.title,
icon: params.icon,
require_admin: params.requireAdmin,
show_in_sidebar: params.showInSidebar,
};
this._open = true;
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._params = undefined;
this._data = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params || !this._data) {
return nothing;
}
const titleInvalid = !this._data.title || !this._data.title.trim();
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
prevent-scrim-close
header-title=${this.hass.localize(
"ui.panel.config.lovelace.dashboards.panel_detail.edit_panel"
)}
@closed=${this._dialogClosed}
>
<ha-dropdown slot="headerActionItems" placement="bottom-end">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item @click=${this._resetPanel}>
<ha-svg-icon slot="icon" .path=${mdiRestart}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.lovelace.dashboards.panel_detail.reset_to_default"
)}
</ha-dropdown-item>
</ha-dropdown>
<ha-form
autofocus
.schema=${this._schema(
this._params.isDefault,
this._data.require_admin
)}
.data=${this._data}
.hass=${this.hass}
.error=${this._error}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
<ha-dialog-footer slot="footer">
<ha-button
appearance="plain"
slot="secondaryAction"
@click=${this.closeDialog}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._updatePanel}
.disabled=${titleInvalid || this._submitting}
>
${this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.update"
)}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _schema = memoizeOne(
(isDefault: boolean, requireAdmin: boolean) =>
[
{
name: "title",
required: true,
selector: { text: {} },
},
{
name: "icon",
required: false,
selector: { icon: {} },
},
{
name: "require_admin",
required: true,
disabled: isDefault && !requireAdmin,
selector: { boolean: {} },
},
{
name: "show_in_sidebar",
required: true,
selector: { boolean: {} },
},
] as const
);
private _computeLabel = (
entry: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(
`ui.panel.config.lovelace.dashboards.panel_detail.${entry.name}`
);
private _computeHelper = (
entry: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
entry.name === "require_admin" && entry.disabled
? this.hass.localize(
"ui.panel.config.lovelace.dashboards.panel_detail.require_admin_helper"
)
: "";
private _valueChanged(ev: CustomEvent) {
this._error = undefined;
this._data = ev.detail.value;
}
private async _handleError(err: any) {
let localizedErrorMessage: string | undefined;
if (err?.translation_domain && err?.translation_key) {
const localize = await this.hass.loadBackendTranslation(
"exceptions",
err.translation_domain
);
localizedErrorMessage = localize(
`component.${err.translation_domain}.exceptions.${err.translation_key}.message`,
err.translation_placeholders
);
}
this._error = {
base: localizedErrorMessage || err?.message || "Unknown error",
};
}
private async _resetPanel() {
this._submitting = true;
try {
await this._params!.updatePanel({
title: null,
icon: null,
require_admin: null,
show_in_sidebar: null,
});
this.closeDialog();
} catch (err: any) {
this._handleError(err);
} finally {
this._submitting = false;
}
}
private async _updatePanel() {
this._submitting = true;
try {
const updates: PanelMutableParams = {};
if (this._data!.title !== this._params!.title) {
updates.title = this._data!.title;
}
if ((this._data!.icon || undefined) !== this._params!.icon) {
updates.icon = this._data!.icon || null;
}
if (this._data!.require_admin !== this._params!.requireAdmin) {
updates.require_admin = this._data!.require_admin;
}
if (this._data!.show_in_sidebar !== this._params!.showInSidebar) {
updates.show_in_sidebar = this._data!.show_in_sidebar;
}
if (Object.keys(updates).length > 0) {
await this._params!.updatePanel(updates);
}
this.closeDialog();
} catch (err: any) {
this._handleError(err);
} finally {
this._submitting = false;
}
}
static styles = haStyleDialog;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-panel-detail": DialogPanelDetail;
}
}

View File

@@ -50,8 +50,12 @@ import {
DEFAULT_PANEL,
getPanelIcon,
getPanelTitle,
updatePanel,
} from "../../../../data/panel";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import "../../../../layouts/hass-loading-screen";
import "../../../../layouts/hass-tabs-subpage-data-table";
import type { HomeAssistant, Route } from "../../../../types";
@@ -60,6 +64,7 @@ import { showNewDashboardDialog } from "../../dashboard/show-dialog-new-dashboar
import { lovelaceTabs } from "../ha-config-lovelace";
import { showDashboardConfigureStrategyDialog } from "./show-dialog-lovelace-dashboard-configure-strategy";
import { showDashboardDetailDialog } from "./show-dialog-lovelace-dashboard-detail";
import { showPanelDetailDialog } from "./show-dialog-panel-detail";
export const PANEL_DASHBOARDS = [
"home",
@@ -282,6 +287,17 @@ export class HaConfigLovelaceDashboards extends LitElement {
action: () => this._handleSetAsDefault(dashboard),
disabled: dashboard.default,
},
...(dashboard.type === "built_in"
? [
{
path: mdiPencil,
label: this.hass.localize(
"ui.panel.config.lovelace.dashboards.picker.edit"
),
action: () => this._handleEditPanel(dashboard),
},
]
: []),
...(dashboard.type === "user_created" &&
dashboard.mode === "storage"
? [
@@ -313,23 +329,27 @@ export class HaConfigLovelaceDashboards extends LitElement {
);
private _getItems = memoize(
(dashboards: LovelaceDashboard[], defaultUrlPath: string | null) => {
(
dashboards: LovelaceDashboard[],
defaultUrlPath: string | null,
panels: HomeAssistant["panels"]
) => {
const result: DataTableItem[] = [];
PANEL_DASHBOARDS.forEach((panel) => {
const panelInfo = this.hass.panels[panel];
const panelInfo = panels[panel];
if (!panelInfo) {
return;
}
const item: DataTableItem = {
icon: getPanelIcon(panelInfo),
title: getPanelTitle(this.hass, panelInfo) || panelInfo.url_path,
show_in_sidebar: true,
show_in_sidebar: panelInfo.show_in_sidebar || false,
mode: "storage",
url_path: panelInfo.url_path,
filename: "",
default: defaultUrlPath === panelInfo.url_path,
require_admin: false,
require_admin: panelInfo.require_admin || false,
type: "built_in",
localized_type: this._localizeType("built_in"),
};
@@ -381,7 +401,11 @@ export class HaConfigLovelaceDashboards extends LitElement {
this._dashboards,
this.hass.localize
)}
.data=${this._getItems(this._dashboards, defaultPanel)}
.data=${this._getItems(
this._dashboards,
defaultPanel,
this.hass.panels
)}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
@@ -452,11 +476,42 @@ export class HaConfigLovelaceDashboards extends LitElement {
this._openDetailDialog(dashboard, urlPath);
}
private _handleEditPanel(item: DataTableItem) {
const panelInfo = this.hass.panels[item.url_path];
if (!panelInfo) {
return;
}
const defaultPanel = this.hass.systemData?.default_panel || DEFAULT_PANEL;
showPanelDetailDialog(this, {
urlPath: panelInfo.url_path,
title: getPanelTitle(this.hass, panelInfo) || panelInfo.url_path,
icon: getPanelIcon(panelInfo),
requireAdmin: panelInfo.require_admin || false,
showInSidebar: panelInfo.show_in_sidebar || false,
isDefault: panelInfo.url_path === defaultPanel,
updatePanel: async (values) => {
await updatePanel(this.hass!, panelInfo.url_path, values);
},
});
}
private _handleSetAsDefault = async (item: DataTableItem) => {
if (item.default) {
return;
}
if (item.require_admin) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.set_default_admin_only_title"
),
text: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.set_default_admin_only_text"
),
});
return;
}
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.set_default_confirm_title"
@@ -524,9 +579,11 @@ export class HaConfigLovelaceDashboards extends LitElement {
urlPath?: string,
defaultConfig?: LovelaceRawConfig
): Promise<void> {
const defaultPanel = this.hass.systemData?.default_panel || DEFAULT_PANEL;
showDashboardDetailDialog(this, {
dashboard,
urlPath,
isDefault: dashboard?.url_path === defaultPanel,
createDashboard: async (values: LovelaceDashboardCreateParams) => {
const created = await createDashboard(this.hass!, values);
this._dashboards = this._dashboards!.concat(created).sort(

View File

@@ -8,6 +8,7 @@ import type {
export interface LovelaceDashboardDetailsDialogParams {
dashboard?: LovelaceDashboard;
urlPath?: string;
isDefault?: boolean;
createDashboard?: (values: LovelaceDashboardCreateParams) => Promise<unknown>;
updateDashboard: (
updates: Partial<LovelaceDashboardMutableParams>

View File

@@ -0,0 +1,25 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { PanelMutableParams } from "../../../../data/panel";
export interface PanelDetailDialogParams {
urlPath: string;
title: string;
icon?: string;
requireAdmin: boolean;
showInSidebar: boolean;
isDefault: boolean;
updatePanel: (updates: PanelMutableParams) => Promise<unknown>;
}
export const loadPanelDetailDialog = () => import("./dialog-panel-detail");
export const showPanelDetailDialog = (
element: HTMLElement,
dialogParams: PanelDetailDialogParams
) => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-panel-detail",
dialogImport: loadPanelDetailDialog,
dialogParams,
});
};

View File

@@ -143,7 +143,8 @@ class HaConfigRepairs extends LitElement {
}
} else if (
issue.domain === "vacuum" &&
issue.translation_key === "segments_changed"
(issue.translation_key === "segments_changed" ||
issue.translation_key === "segments_mapping_not_configured")
) {
const data = await fetchRepairsIssueData(
this.hass.connection,

View File

@@ -324,12 +324,11 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
localize("ui.panel.config.scene.picker.only_editable")
),
actions: {
lastFixed: true,
title: "",
label: this.hass.localize("ui.panel.config.generic.headers.actions"),
type: "overflow-menu",
showNarrow: true,
moveable: false,
hideable: false,
template: (scene) => html`
<ha-icon-overflow-menu
.hass=${this.hass}

View File

@@ -318,12 +318,11 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
labels: getLabelsTableColumn(),
last_triggered: getTriggeredAtTableColumn(localize, this.hass),
actions: {
lastFixed: true,
title: "",
label: this.hass.localize("ui.panel.config.generic.headers.actions"),
type: "overflow-menu",
showNarrow: true,
moveable: false,
hideable: false,
template: (script) => html`
<ha-icon-overflow-menu
.hass=${this.hass}

View File

@@ -21,12 +21,12 @@ export function getAssistantsTableColumn<T>(
defaultHidden: !visible,
sortable: true,
showNarrow: true,
minWidth: "160px",
maxWidth: "160px",
minWidth: "112px",
maxWidth: "112px",
valueColumn: "assistants_sortable_key",
template: (entry: any) =>
html`${entry.assistants.length !== 0
? html`<div style="display: flex; gap: var(--ha-space-4);">
? html`<div style="display: flex; gap: var(--ha-space-1);">
${availableAssistants.map((vaId) => {
const supported =
!supportedEntities?.[vaId] ||

View File

@@ -321,7 +321,16 @@ class PanelEnergy extends LitElement {
private _navigateConfig(ev?: Event) {
ev?.stopPropagation();
navigate("/config/energy?historyBack=1");
const viewPath = this.route?.path?.split("/")[1] || "";
const tabMap: Record<string, string> = {
overview: "electricity",
electricity: "electricity",
gas: "gas",
water: "water",
now: "electricity",
};
const tab = tabMap[viewPath] || "electricity";
navigate(`/config/energy/${tab}?historyBack=1`);
}
private _reloadConfig() {

View File

@@ -1,12 +1,13 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { getEnergyDataCollection } from "../../../data/energy";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy";
import { shouldShowFloorsAndAreas } from "./show-floors-and-areas";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
@customElement("power-view-strategy")
export class PowerViewStrategy extends ReactiveElement {
@@ -14,11 +15,6 @@ export class PowerViewStrategy extends ReactiveElement {
_config: LovelaceStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const view: LovelaceViewConfig = {
type: "sections",
sections: [{ type: "grid", cards: [] }],
};
const collectionKey =
_config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY;
@@ -39,16 +35,46 @@ export class PowerViewStrategy extends ReactiveElement {
const hasPowerDevices = prefs?.device_consumption.some(
(device) => device.stat_rate
);
const hasWaterDevices = prefs?.device_consumption_water.some(
(device) => device.stat_rate
);
const hasWaterSources = prefs?.energy_sources.some(
(source) => source.type === "water" && source.stat_rate
);
const hasGasSources = prefs?.energy_sources.some(
(source) => source.type === "gas" && source.stat_rate
);
// No power sources configured
if (!prefs || (!hasPowerSources && !hasPowerDevices)) {
const chartsSection: LovelaceSectionConfig = {
type: "grid",
cards: [],
};
const badges: LovelaceBadgeConfig[] = [];
const view: LovelaceViewConfig = {
type: "sections",
sections: [chartsSection],
};
// No sources configured
if (
!prefs ||
(!hasPowerSources &&
!hasPowerDevices &&
!hasWaterDevices &&
!hasWaterSources &&
!hasGasSources)
) {
return view;
}
const section = view.sections![0] as LovelaceSectionConfig;
if (hasPowerSources) {
section.cards!.push({
badges.push({
type: "power-total",
collection_key: collectionKey,
});
chartsSection.cards!.push({
title: hass.localize("ui.panel.energy.cards.power_sources_graph_title"),
type: "power-sources-graph",
collection_key: collectionKey,
@@ -58,13 +84,27 @@ export class PowerViewStrategy extends ReactiveElement {
});
}
if (hasGasSources) {
badges.push({
type: "gas-total",
collection_key: collectionKey,
});
}
if (hasWaterSources) {
badges.push({
type: "water-total",
collection_key: collectionKey,
});
}
if (hasPowerDevices) {
const showFloorsAndAreas = shouldShowFloorsAndAreas(
prefs.device_consumption,
hass,
(d) => d.stat_rate
);
section.cards!.push({
chartsSection.cards!.push({
title: hass.localize("ui.panel.energy.cards.power_sankey_title"),
type: "power-sankey",
collection_key: collectionKey,
@@ -76,6 +116,28 @@ export class PowerViewStrategy extends ReactiveElement {
});
}
if (hasWaterDevices) {
const showFloorsAndAreas = shouldShowFloorsAndAreas(
prefs.device_consumption_water,
hass,
(d) => d.stat_rate
);
chartsSection.cards!.push({
title: hass.localize("ui.panel.energy.cards.water_flow_sankey_title"),
type: "water-flow-sankey",
collection_key: collectionKey,
group_by_floor: showFloorsAndAreas,
group_by_area: showFloorsAndAreas,
grid_options: {
columns: 36,
},
});
}
if (badges.length) {
view.badges = badges;
}
return view;
}
}

View File

@@ -101,7 +101,8 @@ class PanelHome extends LitElement {
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors
oldHass.floors !== this.hass.floors ||
oldHass.panels !== this.hass.panels
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();

View File

@@ -58,7 +58,8 @@ class PanelLight extends LitElement {
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors
oldHass.floors !== this.hass.floors ||
oldHass.panels !== this.hass.panels
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();

View File

@@ -64,10 +64,12 @@ const processAreasForLight = (
heading_style: "subtitle",
type: "heading",
heading: area.name,
tap_action: {
action: "navigate",
navigation_path: `/home/areas-${area.area_id}`,
},
tap_action: hass.panels.home
? {
action: "navigate",
navigation_path: `/home/areas-${area.area_id}`,
}
: undefined,
badges: [
// Toggle buttons for mobile
{
@@ -107,18 +109,35 @@ const processAreasForLight = (
// Toggle group card for desktop
cards.push({
type: "toggle-group",
title: hass.localize("ui.panel.lovelace.strategy.light.all_lights"),
color: "amber",
entities: areaLights,
visibility: [LARGE_SCREEN_CONDITION],
grid_options: {
columns: 6,
rows: 1,
min_columns: 6,
},
} as ToggleGroupCardConfig);
cards.push(...areaCards);
areaCards.forEach((card) => {
// Insert a blank card before every 3rd card to align the individual
// cards with the toggle group card on desktop
if (
areaCards.indexOf(card) % 3 === 0 &&
areaCards.indexOf(card) !== 0
) {
cards.push({
type: "vertical-stack",
cards: [],
visibility: [LARGE_SCREEN_CONDITION],
grid_options: {
columns: 6,
rows: 1,
},
});
}
cards.push(card);
});
}
}

View File

@@ -0,0 +1,124 @@
import { mdiFire } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../components/ha-badge";
import "../../../../components/ha-svg-icon";
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
import {
formatFlowRateShort,
getEnergyDataCollection,
getFlowRateFromState,
} from "../../../../data/energy";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceBadge } from "../../types";
import type { GasTotalBadgeConfig } from "../types";
@customElement("hui-gas-total-badge")
export class HuiGasTotalBadge
extends SubscribeMixin(LitElement)
implements LovelaceBadge
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: GasTotalBadgeConfig;
@state() private _data?: EnergyData;
private _entities = new Set<string>();
protected hassSubscribeRequiredHostProps = ["_config"];
public setConfig(config: GasTotalBadgeConfig): void {
this._config = config;
}
public hassSubscribe(): UnsubscribeFunc[] {
return [
getEnergyDataCollection(this.hass, {
key: this._config?.collection_key,
}).subscribe((data) => {
this._data = data;
}),
];
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
if (changedProps.has("_config") || changedProps.has("_data")) {
return true;
}
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || !this._entities.size) {
return true;
}
for (const entityId of this._entities) {
if (oldHass.states[entityId] !== this.hass?.states[entityId]) {
return true;
}
}
}
return false;
}
private _getCurrentFlowRate(entityId: string): number {
this._entities.add(entityId);
return getFlowRateFromState(this.hass.states[entityId]) ?? 0;
}
private _computeTotalFlowRate(prefs: EnergyPreferences): number {
this._entities.clear();
let totalFlow = 0;
prefs.energy_sources.forEach((source) => {
if (source.type === "gas" && source.stat_rate) {
const value = this._getCurrentFlowRate(source.stat_rate);
if (value > 0) totalFlow += value;
}
});
return Math.max(0, totalFlow);
}
protected render() {
if (!this._config || !this._data) {
return nothing;
}
const flowRate = this._computeTotalFlowRate(this._data.prefs);
const displayValue = formatFlowRateShort(
this.hass.locale,
this.hass.config.unit_system.length,
flowRate
);
const name =
this._config.title ||
this.hass.localize("ui.panel.lovelace.cards.energy.gas_total_title");
return html`
<ha-badge .label=${name}>
<ha-svg-icon slot="icon" .path=${mdiFire}></ha-svg-icon>
${displayValue}
</ha-badge>
`;
}
static styles = css`
ha-badge {
--badge-color: var(--energy-gas-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-gas-total-badge": HuiGasTotalBadge;
}
}

View File

@@ -0,0 +1,143 @@
import { mdiHomeLightningBolt } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { formatNumber } from "../../../../common/number/format_number";
import "../../../../components/ha-badge";
import "../../../../components/ha-svg-icon";
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
import {
getEnergyDataCollection,
getPowerFromState,
} from "../../../../data/energy";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceBadge } from "../../types";
import type { PowerTotalBadgeConfig } from "../types";
@customElement("hui-power-total-badge")
export class HuiPowerTotalBadge
extends SubscribeMixin(LitElement)
implements LovelaceBadge
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: PowerTotalBadgeConfig;
@state() private _data?: EnergyData;
private _entities = new Set<string>();
protected hassSubscribeRequiredHostProps = ["_config"];
public setConfig(config: PowerTotalBadgeConfig): void {
this._config = config;
}
public hassSubscribe(): UnsubscribeFunc[] {
return [
getEnergyDataCollection(this.hass, {
key: this._config?.collection_key,
}).subscribe((data) => {
this._data = data;
}),
];
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
if (changedProps.has("_config") || changedProps.has("_data")) {
return true;
}
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || !this._entities.size) {
return true;
}
for (const entityId of this._entities) {
if (oldHass.states[entityId] !== this.hass?.states[entityId]) {
return true;
}
}
}
return false;
}
private _getCurrentPower(entityId: string): number {
this._entities.add(entityId);
return getPowerFromState(this.hass.states[entityId]) ?? 0;
}
private _computeTotalPower(prefs: EnergyPreferences): number {
this._entities.clear();
let solar = 0;
let fromGrid = 0;
let toGrid = 0;
let fromBattery = 0;
let toBattery = 0;
prefs.energy_sources.forEach((source) => {
if (source.type === "solar" && source.stat_rate) {
const value = this._getCurrentPower(source.stat_rate);
if (value > 0) solar += value;
} else if (source.type === "grid" && source.stat_rate) {
const value = this._getCurrentPower(source.stat_rate);
if (value > 0) fromGrid += value;
else if (value < 0) toGrid += Math.abs(value);
} else if (source.type === "battery" && source.stat_rate) {
const value = this._getCurrentPower(source.stat_rate);
if (value > 0) fromBattery += value;
else if (value < 0) toBattery += Math.abs(value);
}
});
const usedTotal = fromGrid + solar + fromBattery - toGrid - toBattery;
return Math.max(0, usedTotal);
}
protected render() {
if (!this._config || !this._data) {
return nothing;
}
const power = this._computeTotalPower(this._data.prefs);
let displayValue = "";
if (power >= 1000) {
displayValue = `${formatNumber(power / 1000, this.hass.locale, {
maximumFractionDigits: 2,
})} kW`;
} else {
displayValue = `${formatNumber(power, this.hass.locale, {
maximumFractionDigits: 0,
})} W`;
}
const name =
this._config.title ||
this.hass.localize("ui.panel.lovelace.cards.energy.power_total_title");
return html`
<ha-badge .label=${name}>
<ha-svg-icon slot="icon" .path=${mdiHomeLightningBolt}></ha-svg-icon>
${displayValue}
</ha-badge>
`;
}
static styles = css`
ha-badge {
--badge-color: var(--primary-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-power-total-badge": HuiPowerTotalBadge;
}
}

View File

@@ -0,0 +1,124 @@
import { mdiWater } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../components/ha-badge";
import "../../../../components/ha-svg-icon";
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
import {
formatFlowRateShort,
getEnergyDataCollection,
getFlowRateFromState,
} from "../../../../data/energy";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceBadge } from "../../types";
import type { WaterTotalBadgeConfig } from "../types";
@customElement("hui-water-total-badge")
export class HuiWaterTotalBadge
extends SubscribeMixin(LitElement)
implements LovelaceBadge
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: WaterTotalBadgeConfig;
@state() private _data?: EnergyData;
private _entities = new Set<string>();
protected hassSubscribeRequiredHostProps = ["_config"];
public setConfig(config: WaterTotalBadgeConfig): void {
this._config = config;
}
public hassSubscribe(): UnsubscribeFunc[] {
return [
getEnergyDataCollection(this.hass, {
key: this._config?.collection_key,
}).subscribe((data) => {
this._data = data;
}),
];
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
if (changedProps.has("_config") || changedProps.has("_data")) {
return true;
}
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || !this._entities.size) {
return true;
}
for (const entityId of this._entities) {
if (oldHass.states[entityId] !== this.hass?.states[entityId]) {
return true;
}
}
}
return false;
}
private _getCurrentFlowRate(entityId: string): number {
this._entities.add(entityId);
return getFlowRateFromState(this.hass.states[entityId]) ?? 0;
}
private _computeTotalFlowRate(prefs: EnergyPreferences): number {
this._entities.clear();
let totalFlow = 0;
prefs.energy_sources.forEach((source) => {
if (source.type === "water" && source.stat_rate) {
const value = this._getCurrentFlowRate(source.stat_rate);
if (value > 0) totalFlow += value;
}
});
return Math.max(0, totalFlow);
}
protected render() {
if (!this._config || !this._data) {
return nothing;
}
const flowRate = this._computeTotalFlowRate(this._data.prefs);
const displayValue = formatFlowRateShort(
this.hass.locale,
this.hass.config.unit_system.length,
flowRate
);
const name =
this._config.title ||
this.hass.localize("ui.panel.lovelace.cards.energy.water_total_title");
return html`
<ha-badge .label=${name}>
<ha-svg-icon slot="icon" .path=${mdiWater}></ha-svg-icon>
${displayValue}
</ha-badge>
`;
}
static styles = css`
ha-badge {
--badge-color: var(--energy-water-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-water-total-badge": HuiWaterTotalBadge;
}
}

View File

@@ -18,6 +18,7 @@ import "../../../components/ha-svg-icon";
import { cameraUrlWithWidthHeight } from "../../../data/camera";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { HomeAssistant } from "../../../types";
import { addBrandsAuth } from "../../../util/brands-url";
import { actionHandler } from "../common/directives/action-handler-directive";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { findEntities } from "../common/find-entities";
@@ -143,7 +144,7 @@ export class HuiEntityBadge extends LitElement implements LovelaceBadge {
if (!entityPicture) return undefined;
let imageUrl = this.hass!.hassUrl(entityPicture);
let imageUrl = this.hass!.hassUrl(addBrandsAuth(entityPicture));
if (computeStateDomain(stateObj) === "camera") {
imageUrl = cameraUrlWithWidthHeight(imageUrl, 32, 32);
}

View File

@@ -48,3 +48,20 @@ export interface EntityBadgeConfig extends LovelaceBadgeConfig {
*/
display_type?: DisplayType;
}
interface EnergyTotalBadgeConfig extends LovelaceBadgeConfig {
title?: string;
collection_key?: string;
}
export interface PowerTotalBadgeConfig extends EnergyTotalBadgeConfig {
type: "power-total";
}
export interface WaterTotalBadgeConfig extends EnergyTotalBadgeConfig {
type: "water-total";
}
export interface GasTotalBadgeConfig extends EnergyTotalBadgeConfig {
type: "gas-total";
}

View File

@@ -28,6 +28,8 @@ import {
formatDateMonthYear,
formatDateShort,
formatDateVeryShort,
formatDateWeekdayShortDate,
formatDateWeekdayVeryShortDate,
} from "../../../../../common/datetime/format_date";
import { formatTime } from "../../../../../common/datetime/format_time";
import type { ECOption } from "../../../../../resources/echarts/echarts";
@@ -222,7 +224,9 @@ function formatTooltip(
if (suggestedPeriod === "month") {
period = `${formatDateMonthYear(date, locale, config)}`;
} else if (suggestedPeriod === "day") {
period = `${(showCompareYear ? formatDateShort : formatDateVeryShort)(date, locale, config)}`;
period = showCompareYear
? formatDateWeekdayShortDate(date, locale, config)
: formatDateWeekdayVeryShortDate(date, locale, config);
} else {
period = `${
compare

View File

@@ -796,7 +796,7 @@ class HuiEnergyDistrubutionCard
</svg>
</div>
</div>
${this._config.link_dashboard
${this._config.link_dashboard && this.hass.panels.energy
? html`
<div class="card-actions">
<ha-button

View File

@@ -302,9 +302,11 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
margin-bottom: 16px;
overflow: hidden;
}
.header:not(:has(> hui-buttons-header-footer)) {
margin-bottom: var(--ha-space-4);
}
.footer {
border-bottom-left-radius: var(

View File

@@ -21,6 +21,7 @@ import { cameraUrlWithWidthHeight } from "../../../data/camera";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import "../../../state-display/state-display";
import type { HomeAssistant } from "../../../types";
import { addBrandsAuth } from "../../../util/brands-url";
import "../card-features/hui-card-features";
import type { LovelaceCardFeatureContext } from "../card-features/types";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
@@ -158,7 +159,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
if (!entityPicture) return undefined;
let imageUrl = this.hass!.hassUrl(entityPicture);
let imageUrl = this.hass!.hassUrl(addBrandsAuth(entityPicture));
if (computeDomain(entity.entity_id) === "camera") {
imageUrl = cameraUrlWithWidthHeight(imageUrl, 80, 80);
}

View File

@@ -73,7 +73,7 @@ export class HuiToggleGroupCard extends LitElement implements LovelaceCard {
return stateColorCss(onEntities[0]);
}
private _computeSecondary(): string {
private _computeLabel(): string {
if (!this.hass || !this._config) return "";
const onCount = this._getOnEntities().length;
const total = this._config.entities.length;
@@ -117,6 +117,10 @@ export class HuiToggleGroupCard extends LitElement implements LovelaceCard {
const style = {
"--tile-color": color,
};
const label = this._computeLabel();
const primary = this._config.title || label;
const secondary = this._config.title ? label : undefined;
return html`
<ha-card style=${styleMap(style)}>
<ha-tile-container .vertical=${Boolean(this._config.vertical)}>
@@ -128,8 +132,8 @@ export class HuiToggleGroupCard extends LitElement implements LovelaceCard {
></ha-tile-icon>
<ha-tile-info
slot="info"
.primary=${this._config.title}
.secondary=${this._computeSecondary()}
.primary=${primary}
.secondary=${secondary}
></ha-tile-info>
</ha-tile-container>
</ha-card>

View File

@@ -251,6 +251,14 @@ export interface WaterSankeyCardConfig extends EnergyCardBaseConfig {
group_by_area?: boolean;
}
export interface WaterFlowSankeyCardConfig extends EnergyCardBaseConfig {
type: "water-flow-sankey";
title?: string;
layout?: "vertical" | "horizontal" | "auto";
group_by_floor?: boolean;
group_by_area?: boolean;
}
export interface PowerSourcesGraphCardConfig extends EnergyCardBaseConfig {
type: "power-sources-graph";
title?: string;

View File

@@ -0,0 +1,628 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import "../../../../components/ha-card";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { EnergyData } from "../../../../data/energy";
import {
formatFlowRateShort,
getEnergyDataCollection,
getFlowRateFromState,
} from "../../../../data/energy";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard, LovelaceGridOptions } from "../../types";
import type { WaterFlowSankeyCardConfig } from "../types";
import "../../../../components/chart/ha-sankey-chart";
import type { Link, Node } from "../../../../components/chart/ha-sankey-chart";
import { getGraphColorByIndex } from "../../../../common/color/colors";
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
const DEFAULT_CONFIG: Partial<WaterFlowSankeyCardConfig> = {
group_by_floor: true,
group_by_area: true,
};
// Minimum flow threshold as a fraction of total inflow to display a device node.
// Devices below this threshold will be grouped into an "Other" node.
const MIN_FLOW_THRESHOLD_FACTOR = 0.001; // 0.1% of total inflow
interface SmallConsumer {
statRate: string;
name: string | undefined;
value: number;
effectiveParent: string | undefined;
idx: number;
}
@customElement("hui-water-flow-sankey-card")
class HuiWaterFlowSankeyCard
extends SubscribeMixin(MobileAwareMixin(LitElement))
implements LovelaceCard
{
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public layout?: string;
@state() private _config?: WaterFlowSankeyCardConfig;
@state() private _data?: EnergyData;
private _entities = new Set<string>();
protected hassSubscribeRequiredHostProps = ["_config"];
public setConfig(config: WaterFlowSankeyCardConfig): void {
this._config = { ...DEFAULT_CONFIG, ...config };
}
public hassSubscribe(): UnsubscribeFunc[] {
return [
getEnergyDataCollection(this.hass, {
key: this._config?.collection_key,
}).subscribe((data) => {
this._data = data;
}),
];
}
public getCardSize(): Promise<number> | number {
return 5;
}
getGridOptions(): LovelaceGridOptions {
return {
columns: 12,
min_columns: 6,
rows: 6,
min_rows: 2,
};
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
if (
changedProps.has("_config") ||
changedProps.has("_data") ||
changedProps.has("_isMobileSize")
) {
return true;
}
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || !this._entities.size) {
return true;
}
for (const entityId of this._entities) {
if (oldHass.states[entityId] !== this.hass.states[entityId]) {
return true;
}
}
}
return false;
}
protected render() {
if (!this._config) {
return nothing;
}
if (!this._data) {
return html`${this.hass.localize(
"ui.panel.lovelace.cards.energy.loading"
)}`;
}
const prefs = this._data.prefs;
const computedStyle = getComputedStyle(this);
// Clear tracked entities and rebuild set
this._entities.clear();
// Collect water sources with stat_rate
const waterSources = prefs.energy_sources.filter(
(source) => source.type === "water" && source.stat_rate
);
let totalInflow = 0;
waterSources.forEach((source) => {
if (source.type === "water" && source.stat_rate) {
const value = this._getCurrentFlowRate(source.stat_rate);
if (value > 0) totalInflow += value;
}
});
// When there are no source meters, pre-compute total device flow so the
// home node has the correct value (sum of all device consumption) rather
// than 0. This avoids a broken sankey where the root node has value=0
// while its children have positive values.
let totalDeviceFlow = 0;
if (waterSources.length === 0) {
prefs.device_consumption_water.forEach((device) => {
if (device.stat_rate) {
totalDeviceFlow += this._getCurrentFlowRate(device.stat_rate);
}
});
}
const effectiveTotalInflow =
waterSources.length === 0 ? totalDeviceFlow : totalInflow;
// Calculate dynamic threshold
const minFlowThreshold = effectiveTotalInflow * MIN_FLOW_THRESHOLD_FACTOR;
const nodes: Node[] = [];
const links: Link[] = [];
const waterColor = computedStyle
.getPropertyValue("--energy-water-color")
.trim();
const primaryColor = computedStyle
.getPropertyValue("--primary-color")
.trim();
// Determine the "root" node for device links.
// - 0 sources: home node (value = sum of device values, computed later)
// - 1 source: that source node is the root (no home node)
// - >1 sources: home node aggregates all sources
const showHomeNode = waterSources.length !== 1;
let rootNodeId: string;
if (showHomeNode) {
// Add source nodes and link to home
waterSources.forEach((source) => {
if (source.type !== "water" || !source.stat_rate) return;
const value = this._getCurrentFlowRate(source.stat_rate);
if (value <= 0) return;
const sourceNodeId = `water_source_${source.stat_rate}`;
nodes.push({
id: sourceNodeId,
label:
this._getEntityLabel(source.stat_rate) ||
this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_distribution.water"
),
value,
color: waterColor,
index: 0,
entityId: source.stat_rate,
});
links.push({ source: sourceNodeId, target: "home" });
});
const homeNode: Node = {
id: "home",
label: this.hass.config.location_name,
value: Math.max(0, effectiveTotalInflow),
color: primaryColor,
index: 1,
};
nodes.push(homeNode);
rootNodeId = "home";
} else {
// Single source: that source IS the root, no home node
const source = waterSources[0];
if (source.type === "water" && source.stat_rate) {
const value = this._getCurrentFlowRate(source.stat_rate);
nodes.push({
id: source.stat_rate,
label:
this._getEntityLabel(source.stat_rate) ||
this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_distribution.water"
),
value: Math.max(0, value),
color: waterColor,
index: 0,
entityId: source.stat_rate,
});
rootNodeId = source.stat_rate;
} else {
// Fallback (shouldn't happen)
rootNodeId = "home";
}
}
// Build a map of device relationships for hierarchy resolution
const deviceMap = new Map<
string,
{ stat_rate?: string; included_in_stat?: string }
>();
prefs.device_consumption_water.forEach((device) => {
deviceMap.set(device.stat_consumption, {
stat_rate: device.stat_rate,
included_in_stat: device.included_in_stat,
});
});
// Set of stat_rate entities that will be rendered as nodes
const renderedStatRates = new Set<string>();
prefs.device_consumption_water.forEach((device) => {
if (device.stat_rate) {
const value = this._getCurrentFlowRate(device.stat_rate);
if (value >= minFlowThreshold) {
renderedStatRates.add(device.stat_rate);
}
}
});
// Find the effective parent for hierarchy
const findEffectiveParent = (
includedInStat: string | undefined
): string | undefined => {
let currentParent = includedInStat;
while (currentParent) {
const parentDevice = deviceMap.get(currentParent);
if (!parentDevice) return undefined;
if (
parentDevice.stat_rate &&
renderedStatRates.has(parentDevice.stat_rate)
) {
return parentDevice.stat_rate;
}
currentParent = parentDevice.included_in_stat;
}
return undefined;
};
let untrackedConsumption = effectiveTotalInflow;
const deviceNodes: Node[] = [];
const parentLinks: Record<string, string> = {};
const smallConsumersByParent = new Map<string, SmallConsumer[]>();
prefs.device_consumption_water.forEach((device, idx) => {
if (!device.stat_rate) return;
const value = this._getCurrentFlowRate(device.stat_rate);
const effectiveParent = findEffectiveParent(device.included_in_stat);
if (value < minFlowThreshold) {
const parentKey = effectiveParent ?? rootNodeId;
if (!smallConsumersByParent.has(parentKey)) {
smallConsumersByParent.set(parentKey, []);
}
smallConsumersByParent.get(parentKey)!.push({
statRate: device.stat_rate,
name: device.name,
value,
effectiveParent,
idx,
});
return;
}
const node = {
id: device.stat_rate,
label: device.name || this._getEntityLabel(device.stat_rate),
value,
color: getGraphColorByIndex(idx, computedStyle),
index: 4,
parent: effectiveParent,
entityId: device.stat_rate,
};
if (node.parent) {
parentLinks[node.id] = node.parent;
links.push({ source: node.parent, target: node.id });
} else {
untrackedConsumption -= value;
}
deviceNodes.push(node);
});
// Process small consumers
smallConsumersByParent.forEach((consumers, parentKey) => {
const totalValue = consumers.reduce((sum, c) => sum + c.value, 0);
if (totalValue <= 0) return;
if (consumers.length === 1) {
const consumer = consumers[0];
const node = {
id: consumer.statRate,
label: consumer.name || this._getEntityLabel(consumer.statRate),
value: consumer.value,
color: getGraphColorByIndex(consumer.idx, computedStyle),
index: 4,
parent: consumer.effectiveParent,
entityId: consumer.statRate,
};
if (node.parent) {
parentLinks[node.id] = node.parent;
links.push({ source: node.parent, target: node.id });
} else {
untrackedConsumption -= consumer.value;
}
deviceNodes.push(node);
} else {
const otherNodeId = `other_${parentKey}`;
const otherNode: Node = {
id: otherNodeId,
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.other"
),
value: Math.ceil(totalValue),
color: computedStyle
.getPropertyValue("--state-unavailable-color")
.trim(),
index: 4,
};
if (parentKey !== rootNodeId) {
parentLinks[otherNodeId] = parentKey;
links.push({ source: parentKey, target: otherNodeId });
} else {
untrackedConsumption -= totalValue;
}
deviceNodes.push(otherNode);
}
});
const devicesWithoutParent = deviceNodes.filter(
(node) => !parentLinks[node.id]
);
const { group_by_area, group_by_floor, layout, title } = this._config;
if (group_by_area || group_by_floor) {
const { areas, floors } = this._groupByFloorAndArea(devicesWithoutParent);
Object.keys(floors)
.sort(
(a, b) =>
(this.hass.floors[b]?.level ?? -Infinity) -
(this.hass.floors[a]?.level ?? -Infinity)
)
.forEach((floorId) => {
let floorNodeId = `floor_${floorId}`;
if (floorId === "no_floor" || !group_by_floor) {
floorNodeId = rootNodeId;
} else {
nodes.push({
id: floorNodeId,
label: this.hass.floors[floorId].name,
value: floors[floorId].value,
index: 2,
color: primaryColor,
});
links.push({ source: rootNodeId, target: floorNodeId });
}
floors[floorId].areas.forEach((areaId) => {
let targetNodeId: string;
if (areaId === "no_area" || !group_by_area) {
targetNodeId = floorNodeId;
} else {
const areaNodeId = `area_${areaId}`;
nodes.push({
id: areaNodeId,
label: this.hass.areas[areaId]?.name || areaId,
value: areas[areaId].value,
index: 3,
color: primaryColor,
});
links.push({
source: floorNodeId,
target: areaNodeId,
value: areas[areaId].value,
});
targetNodeId = areaNodeId;
}
areas[areaId].devices.forEach((device) => {
links.push({
source: targetNodeId,
target: device.id,
value: device.value,
});
});
});
});
} else {
devicesWithoutParent.forEach((deviceNode) => {
links.push({
source: rootNodeId,
target: deviceNode.id,
value: deviceNode.value,
});
});
}
const deviceSections = this._getDeviceSections(parentLinks, deviceNodes);
deviceSections.forEach((section, index) => {
section.forEach((node: Node) => {
nodes.push({ ...node, index: 4 + index });
});
});
// Untracked consumption (only show if > 1 L/min threshold)
if (untrackedConsumption > 1) {
nodes.push({
id: "untracked",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
),
value: untrackedConsumption,
color: computedStyle
.getPropertyValue("--state-unavailable-color")
.trim(),
index: 3 + deviceSections.length,
});
links.push({
source: rootNodeId,
target: "untracked",
value: untrackedConsumption,
});
}
const hasData = nodes.some((node) => node.value > 0);
const vertical =
layout === "vertical" || (layout !== "horizontal" && this._isMobileSize);
return html`
<ha-card
.header=${title}
class=${classMap({
"is-grid": this.layout === "grid",
"is-panel": this.layout === "panel",
"is-vertical": vertical,
})}
>
<div class="card-content">
${hasData
? html`<ha-sankey-chart
.data=${{ nodes, links }}
.vertical=${vertical}
.valueFormatter=${this._valueFormatter}
@node-click=${this._handleNodeClick}
></ha-sankey-chart>`
: html`${this.hass.localize(
"ui.panel.lovelace.cards.energy.no_data"
)}`}
</div>
</ha-card>
`;
}
private _valueFormatter = (value: number) =>
`<div style="direction:ltr; display: inline;">
${formatFlowRateShort(this.hass.locale, this.hass.config.unit_system.length, value)}
</div>`;
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
const { node } = ev.detail;
if (node.entityId) {
fireEvent(this, "hass-more-info", { entityId: node.entityId });
}
}
private _getCurrentFlowRate(entityId: string): number {
this._entities.add(entityId);
return getFlowRateFromState(this.hass.states[entityId]) ?? 0;
}
private _getEntityLabel(entityId: string): string {
const stateObj = this.hass.states[entityId];
if (!stateObj) return entityId;
return stateObj.attributes.friendly_name || entityId;
}
protected _groupByFloorAndArea(deviceNodes: Node[]) {
const areas: Record<string, { value: number; devices: Node[] }> = {
no_area: { value: 0, devices: [] },
};
const floors: Record<string, { value: number; areas: string[] }> = {
no_floor: { value: 0, areas: ["no_area"] },
};
deviceNodes.forEach((deviceNode) => {
const entity = this.hass.states[deviceNode.id];
const { area, floor } = entity
? getEntityContext(
entity,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: { area: null, floor: null };
if (area) {
if (area.area_id in areas) {
areas[area.area_id].value += deviceNode.value;
areas[area.area_id].devices.push(deviceNode);
} else {
areas[area.area_id] = {
value: deviceNode.value,
devices: [deviceNode],
};
}
if (floor) {
if (floor.floor_id in floors) {
floors[floor.floor_id].value += deviceNode.value;
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
floors[floor.floor_id].areas.push(area.area_id);
}
} else {
floors[floor.floor_id] = {
value: deviceNode.value,
areas: [area.area_id],
};
}
} else {
floors.no_floor.value += deviceNode.value;
if (!floors.no_floor.areas.includes(area.area_id)) {
floors.no_floor.areas.unshift(area.area_id);
}
}
} else {
areas.no_area.value += deviceNode.value;
areas.no_area.devices.push(deviceNode);
}
});
return { areas, floors };
}
protected _getDeviceSections(
parentLinks: Record<string, string>,
deviceNodes: Node[]
): Node[][] {
const parentSection: Node[] = [];
const childSection: Node[] = [];
const parentIds = Object.values(parentLinks);
const remainingLinks: typeof parentLinks = {};
deviceNodes.forEach((deviceNode) => {
const isChild = deviceNode.id in parentLinks;
const isParent = parentIds.includes(deviceNode.id);
if (isParent && !isChild) {
parentSection.push(deviceNode);
} else {
childSection.push(deviceNode);
}
});
Object.entries(parentLinks).forEach(([child, parent]) => {
if (!parentSection.some((node) => node.id === parent)) {
remainingLinks[child] = parent;
}
});
if (parentSection.length > 0) {
return [
parentSection,
...this._getDeviceSections(remainingLinks, childSection),
];
}
return [deviceNodes];
}
static styles = css`
ha-card {
height: 400px;
display: flex;
flex-direction: column;
--chart-max-height: none;
}
ha-card.is-vertical {
height: 500px;
}
ha-card.is-grid,
ha-card.is-panel {
height: 100%;
}
.card-content {
flex: 1;
display: flex;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-water-flow-sankey-card": HuiWaterFlowSankeyCard;
}
}

View File

@@ -10,6 +10,9 @@ const ALWAYS_LOADED_TYPES = new Set(["error", "entity"]);
const LAZY_LOAD_TYPES = {
"entity-filter": () => import("../badges/hui-entity-filter-badge"),
"state-label": () => import("../badges/hui-state-label-badge"),
"power-total": () => import("../badges/energy/hui-power-total-badge"),
"gas-total": () => import("../badges/energy/hui-gas-total-badge"),
"water-total": () => import("../badges/energy/hui-water-total-badge"),
};
// This will not return an error card but will throw the error

View File

@@ -67,6 +67,8 @@ const LAZY_LOAD_TYPES = {
import("../cards/energy/hui-energy-usage-graph-card"),
"energy-sankey": () => import("../cards/energy/hui-energy-sankey-card"),
"water-sankey": () => import("../cards/water/hui-water-sankey-card"),
"water-flow-sankey": () =>
import("../cards/water/hui-water-flow-sankey-card"),
"power-sources-graph": () =>
import("../cards/energy/hui-power-sources-graph-card"),
"power-sankey": () => import("../cards/energy/hui-power-sankey-card"),

View File

@@ -142,7 +142,6 @@ export class HuiCreateDialogBadge
`
: html`
<hui-entity-picker-table
no-label-float
.hass=${this.hass}
.narrow=${true}
@selected-changed=${this._handleSelectedChanged}

View File

@@ -127,26 +127,30 @@ export class HuiCreateDialogCard
></ha-icon-button>
<span slot="title">${title}</span>
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
<ha-tab-group-tab
slot="nav"
.active=${this._currTab === "card"}
panel="card"
?autofocus=${this._narrow}
>
${this.hass!.localize(
"ui.panel.lovelace.editor.cardpicker.by_card"
)}
</ha-tab-group-tab>
<ha-tab-group-tab
slot="nav"
.active=${this._currTab === "entity"}
panel="entity"
>${this.hass!.localize(
"ui.panel.lovelace.editor.cardpicker.by_entity"
)}</ha-tab-group-tab
>
</ha-tab-group>
${!this._params.saveCard
? html`
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
<ha-tab-group-tab
slot="nav"
.active=${this._currTab === "card"}
panel="card"
?autofocus=${this._narrow}
>
${this.hass!.localize(
"ui.panel.lovelace.editor.cardpicker.by_card"
)}
</ha-tab-group-tab>
<ha-tab-group-tab
slot="nav"
.active=${this._currTab === "entity"}
panel="entity"
>${this.hass!.localize(
"ui.panel.lovelace.editor.cardpicker.by_entity"
)}</ha-tab-group-tab
>
</ha-tab-group>
`
: nothing}
</ha-dialog-header>
${cache(
this._currTab === "card"
@@ -161,7 +165,6 @@ export class HuiCreateDialogCard
`
: html`
<hui-entity-picker-table
no-label-float
.hass=${this.hass}
narrow
@selected-changed=${this._handleSelectedChanged}
@@ -255,6 +258,17 @@ export class HuiCreateDialogCard
}
}
if (this._params!.saveCard) {
showEditCardDialog(this, {
lovelaceConfig: this._params!.lovelaceConfig,
saveCardConfig: this._params!.saveCard,
cardConfig: config,
isNew: true,
});
this.closeDialog();
return;
}
const lovelaceConfig = this._params!.lovelaceConfig;
const containerPath = this._params!.path;
const saveConfig = this._params!.saveConfig;

View File

@@ -43,9 +43,6 @@ export class HuiEntityPickerTable extends LitElement {
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, attribute: "no-label-float" })
public noLabelFloat? = false;
@property({ type: Array }) public entities?: string[];
protected firstUpdated(_changedProperties: PropertyValues): void {
@@ -115,7 +112,6 @@ export class HuiEntityPickerTable extends LitElement {
.searchLabel=${this.hass.localize(
"ui.panel.lovelace.unused_entities.search"
)}
.noLabelFloat=${this.noLabelFloat}
.noDataText=${this.hass.localize(
"ui.panel.lovelace.unused_entities.no_data"
)}

View File

@@ -1,4 +1,5 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelaceContainerPath } from "../lovelace-path";
@@ -8,6 +9,7 @@ export interface CreateCardDialogParams {
path: LovelaceContainerPath;
suggestedCards?: string[];
entities?: string[]; // We can pass entity id's that will be added to the config when a card is picked
saveCard?: (cardConfig: LovelaceCardConfig) => void; // Optional: pick a single card and return it via callback, hides entity tab
}
export const importCreateCardDialog = () => import("./hui-dialog-create-card");

View File

@@ -128,52 +128,7 @@ export class HuiSaveConfig extends LitElement implements HassDialog {
`
}
</div>
${
this._params.mode === "storage"
? html`
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this.closeDialog}
>
${this.hass!.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._saveConfig}
.loading=${this._saving}
>
${this.hass!.localize(
"ui.panel.lovelace.editor.save_config.save"
)}
</ha-button>
</ha-dialog-footer>
`
: html`
<p>
${this.hass!.localize(
"ui.panel.lovelace.editor.save_config.yaml_mode"
)}
</p>
<p>
${this.hass!.localize(
"ui.panel.lovelace.editor.save_config.yaml_control"
)}
</p>
<p>
${this.hass!.localize(
"ui.panel.lovelace.editor.save_config.yaml_config"
)}
</p>
<ha-yaml-editor
.hass=${this.hass}
.defaultValue=${this._params!.lovelace.config}
autofocus
></ha-yaml-editor>
`
}
</div>
${
this._params.mode === "storage"
? html`

View File

@@ -46,6 +46,8 @@ import "./hui-section-settings-editor";
import "./hui-section-visibility-editor";
import type { EditSectionDialogParams } from "./show-edit-section-dialog";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import { getViewType } from "../../views/get-view-type";
import { SECTIONS_VIEW_LAYOUT } from "../../views/const";
const TABS = ["tab-settings", "tab-visibility"] as const;
@@ -290,13 +292,16 @@ export class HuiDialogEditSection
const toView = selectedDashConfig.views[viewIndex];
if (isStrategyView(toView)) {
if (
isStrategyView(toView) ||
getViewType(toView) !== SECTIONS_VIEW_LAYOUT
) {
showAlertDialog(this, {
title: this.hass!.localize(
"ui.panel.lovelace.editor.move_section.error_title"
),
text: this.hass!.localize(
"ui.panel.lovelace.editor.move_section.error_text_strategy"
"ui.panel.lovelace.editor.move_section.error_text"
),
warning: true,
});

View File

@@ -0,0 +1,211 @@
import { mdiDotsVertical, mdiPlaylistEdit } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { deepEqual } from "../../../../common/util/deep-equal";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-spinner";
import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import type { LovelaceViewFooterConfig } from "../../../../data/lovelace/config/view";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import {
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "./hui-view-footer-settings-editor";
import type { EditViewFooterDialogParams } from "./show-edit-view-footer-dialog";
@customElement("hui-dialog-edit-view-footer")
export class HuiDialogEditViewFooter extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: EditViewFooterDialogParams;
@state() private _config?: LovelaceViewFooterConfig;
@state() private _saving = false;
@state() private _dirty = false;
@state() private _yamlMode = false;
@query("ha-yaml-editor") private _editor?: HaYamlEditor;
@state() private _open = false;
protected updated(changedProperties: PropertyValues) {
if (this._yamlMode && changedProperties.has("_yamlMode")) {
const config = {
...this._config,
};
this._editor?.setValue(config);
}
}
public showDialog(params: EditViewFooterDialogParams): void {
this._params = params;
this._dirty = false;
this._config = this._params.config;
this._open = true;
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._params = undefined;
this._config = undefined;
this._yamlMode = false;
this._dirty = false;
this._saving = false;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params || !this.hass) {
return nothing;
}
let content: TemplateResult;
if (this._yamlMode) {
content = html`
<ha-yaml-editor
.hass=${this.hass}
autofocus
@value-changed=${this._viewYamlChanged}
></ha-yaml-editor>
`;
} else {
content = html`
<hui-view-footer-settings-editor
.hass=${this.hass}
.config=${this._config}
.maxColumns=${this._params.maxColumns}
@config-changed=${this._configChanged}
></hui-view-footer-settings-editor>
`;
}
const title = this.hass.localize(
"ui.panel.lovelace.editor.edit_view_footer.header"
);
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${title}
.width=${this._yamlMode ? "full" : "large"}
@closed=${this._dialogClosed}
class=${this._yamlMode ? "yaml-mode" : ""}
>
<ha-dropdown
slot="headerActionItems"
placement="bottom-end"
@wa-select=${this._handleAction}
>
<ha-icon-button
slot="trigger"
.label=${this.hass!.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="toggle-mode">
${this.hass!.localize(
`ui.panel.lovelace.editor.edit_view_footer.edit_${!this._yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon slot="icon" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-dropdown-item>
</ha-dropdown>
${content}
<ha-dialog-footer slot="footer">
<ha-button
slot="primaryAction"
.disabled=${!this._config || !this._dirty}
@click=${this._save}
.loading=${this._saving}
>
${this.hass!.localize("ui.common.save")}</ha-button
>
</ha-dialog-footer>
</ha-dialog>
`;
}
private async _handleAction(ev: HaDropdownSelectEvent) {
const action = ev.detail.item.value;
if (action === "toggle-mode") {
this._yamlMode = !this._yamlMode;
}
}
private _configChanged(ev: CustomEvent): void {
if (
ev.detail &&
ev.detail.config &&
!deepEqual(this._config, ev.detail.config)
) {
this._config = ev.detail.config;
this._dirty = true;
}
}
private _viewYamlChanged(ev: CustomEvent) {
ev.stopPropagation();
if (!ev.detail.isValid) {
return;
}
this._config = ev.detail.value;
this._dirty = true;
}
private async _save(): Promise<void> {
if (!this._params || !this._config) {
return;
}
this._saving = true;
try {
await this._params.saveConfig(this._config);
this.closeDialog();
} catch (err: any) {
showAlertDialog(this, {
text: `${this.hass!.localize(
"ui.panel.lovelace.editor.edit_view_footer.saving_failed"
)}: ${err.message}`,
});
} finally {
this._saving = false;
}
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-dialog.yaml-mode {
--dialog-content-padding: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-dialog-edit-view-footer": HuiDialogEditViewFooter;
}
}

View File

@@ -0,0 +1,87 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { LovelaceViewFooterConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
@customElement("hui-view-footer-settings-editor")
export class HuiViewFooterSettingsEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public config?: LovelaceViewFooterConfig;
@property({ attribute: false }) public maxColumns = 4;
private _schema = memoizeOne(
(maxColumns: number) =>
[
{
name: "column_span",
selector: {
number: {
min: 1,
max: maxColumns,
slider_ticks: true,
},
},
},
] as const satisfies HaFormSchema[]
);
protected render() {
const data = {
column_span: this.config?.column_span || 1,
};
const schema = this._schema(this.maxColumns);
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation();
const newData = ev.detail.value;
const config: LovelaceViewFooterConfig = {
...this.config,
...newData,
};
fireEvent(this, "config-changed", { config });
}
private _computeLabel = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) =>
this.hass.localize(
`ui.panel.lovelace.editor.edit_view_footer.settings.${schema.name}`
);
private _computeHelper = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) =>
this.hass.localize(
`ui.panel.lovelace.editor.edit_view_footer.settings.${schema.name}_helper`
) || "";
}
declare global {
interface HTMLElementTagNameMap {
"hui-view-footer-settings-editor": HuiViewFooterSettingsEditor;
}
}

Some files were not shown because too many files have changed in this diff Show More