Compare commits

...

76 Commits

Author SHA1 Message Date
Aidan Timson
7572257821 Match expose config dashboard for assistants columns (#28956) 2026-01-14 11:43:56 +01:00
Pegasus
4703cf802f Change border-quiet token values from 80 to 90 (#28976) 2026-01-14 09:28:32 +00:00
ildar170975
55c2315329 ha-label-picker: remove valueRenderer (#28975) 2026-01-14 10:15:39 +01:00
Wendelin
7d7e95ac55 Improve device automation UI (#28967)
* Improve device automation rows

* Improve device automation type picker

* Update src/panels/config/automation/condition/ha-automation-condition-row.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-14 07:06:56 +00:00
ildar170975
6d7694caff ha-label-picker, ha-category-picker: fix icon for "no items available" (#28973)
* remove NO_LABELS

* remove NO_CATEGORIES

* reverted removed icon
2026-01-14 08:42:24 +02:00
calm
d7b6243698 Fix tree view heading overlapping Show more button (#28872) (#28968) 2026-01-13 18:34:39 +01:00
calm
73feef9e92 Remove box-shadow from automation dialog "Show more" button (#28945) (#28960) 2026-01-13 17:31:55 +01:00
renovate[bot]
453a546574 Update Node.js to v24.13.0 (#28963)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 15:16:03 +00:00
Petar Petrov
52c0e6f1f5 Respect user-configured grid options for fixed_rows/fixed_columns cards (#28961) 2026-01-13 16:24:25 +02:00
Aidan Timson
444f8d87b3 Ignore all node_modules, not just from root dir (#28959) 2026-01-13 13:51:54 +01:00
Pegasus
57a586c3a7 fix: update the z-index of search button mainly for yaml mode (#28878) 2026-01-13 13:41:53 +01:00
Pegasus
1975265e6b Update the Select Option type from any to string per documentation (#28954) 2026-01-13 10:44:02 +01:00
Wendelin
66e6cb8dbc Fix category-picker unknown check (#28957) 2026-01-13 09:39:05 +00:00
Petar Petrov
9ce9d254f8 Picture elements position by click (#28597) 2026-01-13 10:01:07 +01:00
ildar170975
1beca4bfa6 ha-data-table: issues with "numeric" column (#28916)
Co-authored-by: uptimeZERO_ <pavilionsahota@gmail.com>
2026-01-13 08:38:15 +00:00
Kristel
82ab29cfc5 Add "Voice assistant" filter to helpers, automations, scenes and scripts pages (#28914) 2026-01-13 08:29:28 +00:00
Simon Lamon
3579c66f71 Update dropdown adjustments (#28294) 2026-01-13 08:54:17 +01:00
ildar170975
c042a8e310 ha-sidebar: remove scrollIntoViewIfNeeded() (#28938)
remove scrollIntoViewIfNeeded()
2026-01-13 07:23:20 +01:00
renovate[bot]
8d2794a4ee Update dependency vite-tsconfig-paths to v6.0.4 (#28952)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 07:15:15 +01:00
Paul Bottein
50be1d9345 Use action button text name for empty state card (#28948) 2026-01-12 17:42:01 +01:00
Petar Petrov
c551bf03b6 Sanitize names in history card and map card (#28947) 2026-01-12 15:28:32 +00:00
Paul Bottein
cd062293fc Add config to empty state card and use it in area empty page (#28946)
* Add config to empty state card and use it in area empty page

* Remove old translations
2026-01-12 16:58:59 +02:00
TheJulianJES
e89ea47d3a Add Matter status to config dashboard (#28825)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2026-01-12 15:45:18 +01:00
SmartCoder
2cd209a6a4 Fixed modal visibility issue in settings -> areas -> edit room (#28907)
* Fixed modal visibility issue in settings -> areas -> edit room

* converting both components to use ha-wa-dialog

* removed z-index from ha-wa-dialog

* fixed hardcoded .open in media browser dialog and remove unnecessary z-index CSS variables
2026-01-12 15:07:56 +02:00
Marcin Bauer
9bbc761736 Fix: Allow dismissing add integration and helper dialogs with escape/click (#28944)
* refactor: polish automation dialog UI and component styles

* Revert "Merge pull request #1 from marcinbauer85/fix/ui-polish-automation-dialog"

This reverts commit c2c47197e2, reversing
changes made to 49bed5e6a6.

* Fix: Allow dismissing add integration and helper dialogs

* Apply suggestions from code review

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-01-12 13:39:16 +01:00
Daniel O'Connor
9097faa04b Config > Helpers > Add loading filter state from URL (#28924) 2026-01-12 13:38:04 +01:00
SmartCoder
fcf844cf1a Fix issue #28896: "Last 12 months" in the Datetime Picker selects last year (#28902)
Summary of the fix:
The Problem:
now-12m was selecting the calendar year (Jan 1st to Dec 31st) instead of the last 12 months from now
It used startOfMonth and endOfMonth, which snap to month boundaries
The Solution:
Changed to match the now-7d and now-30d pattern
Now uses subMonths(today, 12) for start and subMonths(today, 0) (which equals today) for end
This gives exactly the last 12 months (365/366 days) ending at the current time
The Fix:
// Before (WRONG):calcDate(subMonths(today, 12), startOfMonth, ...)  // Jan 1st of 12 months agocalcDate(subMonths(today, 1), endOfMonth, ...)     // Dec 31st of last month// After (CORRECT):calcDate(today, subMonths, hass.locale, hass.config, 12)  // 12 months ago from nowcalcDate(today, subMonths, hass.locale, hass.config, 0)   // now
2026-01-12 11:53:08 +00:00
dcapslock
8808c31e98 Fix ha-card styling of .card-content when not first element but not following .card-header (#28935) 2026-01-12 12:41:14 +01:00
Michael
e0a9f5a08a Show also not installable updates on update overview page (#28717)
* add "show not installable option" to update page

* split updates by install feature and show always

* fix

* fix "no update" panel

* use `nothing` instead of empty string

* re-add `outlined` to ha-card

* keep title, use different for not-installable updates
2026-01-12 13:18:53 +02:00
Petar Petrov
56d71c8e54 Use temp & humidity data from attributes in Area card (#28530)
* Use temp & humidity data from attributes in Area card

* Avoid duplicate sensor readings by tracking devices contributing values
2026-01-12 12:01:12 +01:00
karwosts
125ab4c671 Update energy summary visibility condition (#28913)
* Update energy summary visibility condition

* add grid power as special case

* Always show summary when you have powersource
2026-01-12 12:42:16 +02:00
Eduardo Tsen
8014216c45 Fix ha-entity-toggle not restoring old state on exception (#28915) 2026-01-12 10:28:23 +00:00
ildar170975
55ba331489 developer-tools-statistics: alignment for "fix" column (#28942) 2026-01-12 11:25:44 +01:00
karwosts
ad2ff672b0 Add configurable confirmation title & button text (#28931) 2026-01-12 10:19:09 +00:00
JLo
00907ecd17 Add area and device context to media player join dialog (#28926)
* Add area and device context to media player join dialog

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Add memoization to avoid recomputing display data

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-12 11:08:44 +01:00
Petar Petrov
07d8219136 Add ES5-compatible keyed directive implementation (#28941) 2026-01-12 10:50:38 +01:00
Eduardo Tsen
f37241c84c Fix hui-select-entity-row restoring old state (#28918) 2026-01-12 09:43:31 +00:00
SmartCoder
65d046132d Updated entity name to friendly name (#28928) 2026-01-12 10:14:23 +01:00
Simon Lamon
122cf40092 Don't close dialog upon tooltip close (#28927) 2026-01-11 20:23:42 -05:00
SmartCoder
28ed5c86c7 Fix automation row menu icon being pushed off-screen on mobile (#28893)
When entity names are too long, the header text would push the three-dot menu icon off the right edge of the screen, making it inaccessible. This fix ensures the menu icon remains visible by:

- Adding min-width: 0 to the header slot to allow proper flexbox shrinking and text wrapping

- Adding flex-shrink: 0 to the icons container to prevent it from being compressed

The fix uses standard flexbox properties that work universally across all screen sizes, ensuring the menu icon stays visible on both mobile and desktop views.
2026-01-10 15:58:28 +01:00
Kristel
1f99c3d895 Add Voice assistants filter to Entities page (#28854)
* create Assistants filter

* render logo and name

* make the Voice assistants filter work

* integrate cloudStatus

* code clean-up

* remove cloudStatus

* bugfix

* remove console log

* remove cloudstatus

* set ha-list clientHeight to 49px
2026-01-10 12:57:33 +01:00
LG-ThinQ-Integration
f2293713de Add target_humidity_step to humidifier (#28005)
Co-authored-by: yunseon.park <yunseon.park@lge.com>
2026-01-10 10:12:55 +01:00
Brendan Annable
b3f202400c Fix timer restore bug (#28898) 2026-01-10 09:51:19 +01:00
ildar170975
010d87bd0d ha-dialog-automation-save: small improvements & fixes (#28561)
* explictly set line-height for "helper" element

* move "description" to bottom, css tweaks

* revert

* revert, make a helper persistent
2026-01-10 09:40:10 +01:00
karwosts
b403b8f09e Implement allow_negative for duration selector (#28909) 2026-01-10 08:58:14 +01:00
karwosts
b9a3dc795b Duration selector: migrate legacy duration formats (#28880) 2026-01-09 20:30:09 +01:00
Bram Kragten
35dbfdebcf Add support for choose selector to initial form data (#28876)
* Add support for choose selector to initial form data

* Update compute-initial-ha-form-data.ts
2026-01-09 19:57:32 +01:00
renovate[bot]
c5e5fb3ace Update formatjs monorepo (#28905)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-09 17:41:54 +00:00
Yosi Levy
e649472b20 Arrow fixes in media browser (#28890) 2026-01-09 18:31:25 +01:00
Yosi Levy
3cbb24a4c5 Fix for volume scroll in media player (#28891) 2026-01-09 18:30:45 +01:00
renovate[bot]
f92608a9d3 Update dependency @codemirror/view to v6.39.9 (#28903)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-09 18:23:11 +01:00
renovate[bot]
6591cdc5c1 Update dependency @rspack/core to v1.7.1 (#28892)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-09 18:22:51 +01:00
renovate[bot]
0ae1ac367d Update dependency lit-html to v3.3.2 (#28762)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-09 08:32:27 +02:00
renovate[bot]
6d3a1b93e1 Update dependency lit to v3.3.2 (#28761)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-08 21:03:20 +01:00
renovate[bot]
6d7b22a21c Update dependency typescript-eslint to v8.52.0 (#28879)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-08 20:54:45 +01:00
Petar Petrov
784ee22623 Removes duplicate closing tag in ha-auth-form-string (#28883) 2026-01-08 20:53:55 +01:00
Aidan Timson
c03654ef8e Fix wa dialog esc behaviour when preventing scrim closure (#28875)
* Fix wa dialog esc behaviour when preventing scrim closure

* Use wa-hide event to prevend closure
2026-01-08 17:10:53 +00:00
Pegasus
826cb3117d Fix: update the id, pan id to capitalize (#28873)
fix: update the id, pan id to capitalize
2026-01-08 12:26:49 +00:00
Aidan Timson
f77fa26ffe Fix type error for calendar card (#28869) 2026-01-08 12:57:46 +02:00
Bram Kragten
35e30f9184 Fix color palette creation (#28867) 2026-01-08 10:14:03 +00:00
DAccord
7dd3ade678 Handling empty history (#28852)
Co-authored-by: DAccord <11232265+DAccord@users.noreply.github.com>
2026-01-08 09:58:38 +00:00
karwosts
6d1e15d11a Add a devtools event listener filter (#28849) 2026-01-08 10:58:24 +01:00
Timothy
f5b33922ff Move companion app settings to a dedicated section in the settings (#28830) 2026-01-08 10:36:50 +01:00
dcapslock
ceb7baf851 Fix choose selector active_choice when card editor config changes (#28858) 2026-01-08 10:20:42 +01:00
ildar170975
d195fd3244 Views: allow showing both icon & text title (#28690) 2026-01-07 19:03:48 +00:00
renovate[bot]
231cd632d6 Update dependency @bundle-stats/plugin-webpack-filter to v4.21.8 (#28846)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 17:23:37 +01:00
Wendelin
82d72ea39c Fix logs provider picker mobile width (#28847) 2026-01-07 16:30:49 +01:00
Wendelin
022bebb14f Throttle unknown value checks in ha-generic-picker (#28842) 2026-01-07 16:14:33 +01:00
Paul Bottein
0981ae1b4a Prefill the field with current value when editing a custom text item (#28840) 2026-01-07 15:42:47 +01:00
Paul Bottein
9608824a28 Remove ha-combo-box-textfield (#28841) 2026-01-07 15:39:58 +01:00
Marcin Bauer
33d215533e Add Shift+/ shortcut to shortcuts dialog and use Unicode command character (#28838)
* refactor: polish automation dialog UI and component styles

* Revert "Merge pull request #1 from marcinbauer85/fix/ui-polish-automation-dialog"

This reverts commit c2c47197e2, reversing
changes made to 49bed5e6a6.

* Add shortcuts dialog shortcut and use Unicode command character

* Update shortcut description text
2026-01-07 14:17:18 +00:00
Paul Bottein
5c503ecac0 Reduce shadow effect for scrollable fade mixin (#28832) 2026-01-07 14:16:41 +00:00
Wendelin
d114693fed Improve device picker performance (#28835) 2026-01-07 14:28:18 +01:00
Kristel
7a8cb80413 Add Voice assistant column to data tables (#28785)
* added Voice assistant column to data tables

* remove commented code

* fix column settings

* code review changes

* reuse voice-assistants-expose-assistant-icon

* refactor getEntityVoiceAssistantsKeys

* fix column width

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-07 13:15:01 +00:00
Marcin Bauer
f5cd234c4b Refactor: Polish automation dialog UI and component styles (#28831)
* refactor: polish automation dialog UI and component styles

* Update ha-automation-row-targets.ts

- added borders to main automation list chips
2026-01-07 09:27:01 +00:00
karwosts
49bed5e6a6 Standardize all energy period calculations (#28827) 2026-01-07 08:46:54 +02:00
105 changed files with 2830 additions and 1062 deletions

2
.gitignore vendored
View File

@@ -15,7 +15,7 @@ dist/
!.yarn/sdks
!.yarn/versions
.pnp.*
/node_modules/
node_modules/
yarn-error.log
npm-debug.log

2
.nvmrc
View File

@@ -1 +1 @@
24.12.0
24.13.0

View File

@@ -213,7 +213,9 @@ const createRspackConfig = ({
"lit/directives/join$": "lit/directives/join.js",
"lit/directives/repeat$": "lit/directives/repeat.js",
"lit/directives/live$": "lit/directives/live.js",
"lit/directives/keyed$": "lit/directives/keyed.js",
"lit/directives/keyed$": latestBuild
? "lit/directives/keyed.js"
: path.resolve(__dirname, "../src/common/lit/keyed-es5.ts"),
"lit/polyfill-support$": "lit/polyfill-support.js",
"@lit-labs/virtualizer/layouts/grid":
"@lit-labs/virtualizer/layouts/grid.js",

View File

@@ -34,18 +34,18 @@
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.5.11",
"@codemirror/state": "6.5.3",
"@codemirror/view": "6.39.8",
"@codemirror/view": "6.39.9",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.1.1",
"@formatjs/intl-displaynames": "7.1.1",
"@formatjs/intl-durationformat": "0.9.1",
"@formatjs/intl-getcanonicallocales": "3.1.1",
"@formatjs/intl-listformat": "8.1.1",
"@formatjs/intl-locale": "5.1.1",
"@formatjs/intl-numberformat": "9.1.1",
"@formatjs/intl-pluralrules": "6.1.1",
"@formatjs/intl-relativetimeformat": "12.1.1",
"@formatjs/intl-datetimeformat": "7.1.2",
"@formatjs/intl-displaynames": "7.1.2",
"@formatjs/intl-durationformat": "0.9.2",
"@formatjs/intl-getcanonicallocales": "3.1.2",
"@formatjs/intl-listformat": "8.1.2",
"@formatjs/intl-locale": "5.1.2",
"@formatjs/intl-numberformat": "9.1.2",
"@formatjs/intl-pluralrules": "6.1.2",
"@formatjs/intl-relativetimeformat": "12.1.2",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
@@ -112,13 +112,13 @@
"hls.js": "1.6.15",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "11.0.8",
"intl-messageformat": "11.0.9",
"js-yaml": "4.1.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.1",
"lit-html": "3.3.1",
"lit": "3.3.2",
"lit-html": "3.3.2",
"luxon": "3.7.2",
"marked": "17.0.1",
"memoize-one": "6.0.0",
@@ -150,13 +150,13 @@
"@babel/helper-define-polyfill-provider": "0.6.5",
"@babel/plugin-transform-runtime": "7.28.5",
"@babel/preset-env": "7.28.5",
"@bundle-stats/plugin-webpack-filter": "4.21.7",
"@bundle-stats/plugin-webpack-filter": "4.21.8",
"@lokalise/node-api": "15.6.0",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.0.3",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.4.0",
"@rspack/core": "1.7.0",
"@rspack/core": "1.7.1",
"@rspack/dev-server": "1.1.5",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.25",
@@ -215,8 +215,8 @@
"terser-webpack-plugin": "5.3.16",
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.51.0",
"vite-tsconfig-paths": "6.0.3",
"typescript-eslint": "8.52.0",
"vite-tsconfig-paths": "6.0.4",
"vitest": "4.0.16",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
@@ -224,8 +224,8 @@
},
"resolutions": {
"@material/mwc-button@^0.25.3": "^0.27.0",
"lit": "3.3.1",
"lit-html": "3.3.1",
"lit": "3.3.2",
"lit-html": "3.3.2",
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.20",
@@ -236,6 +236,6 @@
},
"packageManager": "yarn@4.12.0",
"volta": {
"node": "24.12.0"
"node": "24.13.0"
}
}

View File

@@ -38,13 +38,11 @@ export class HaAuthFormString extends HaFormString {
}
</style>
<ha-auth-textfield
.type=${
!this.isPassword
.type=${!this.isPassword
? this.stringType
: this.unmaskedPassword
? "text"
: "password"
}
: "password"}
.label=${this.label}
.value=${this.data || ""}
.helper=${this.helper}
@@ -55,18 +53,17 @@ export class HaAuthFormString extends HaFormString {
.name=${this.schema.name}
.autocomplete=${this.schema.autocomplete}
?autofocus=${this.schema.autofocus}
.suffix=${
this.isPassword
? // reserve some space for the icon.
html`<div style="width: 24px"></div>`
: this.schema.description?.suffix
}
.validationMessage=${this.schema.required ? this.localize?.("ui.panel.page-authorize.form.error_required") : undefined}
.suffix=${this.isPassword
? // reserve some space for the icon.
html`<div style="width: 24px"></div>`
: this.schema.description?.suffix}
.validationMessage=${this.schema.required
? this.localize?.("ui.panel.page-authorize.form.error_required")
: undefined}
@input=${this._valueChanged}
@change=${this._valueChanged}
></ha-auth-textfield>
${this.renderIcon()}
</ha-auth-textfield>
></ha-auth-textfield>
${this.renderIcon()}
`;
}
}

View File

@@ -79,7 +79,7 @@ export const generateColorPalette = (
}
return steps.map((step) => {
const name = `color-${label}-${step}`;
const name = `ha-color-${label}-${step}`;
// Base color at 50%
if (step === 50) {

View File

@@ -93,8 +93,8 @@ export const calcDateRange = (
];
case "now-12m":
return [
calcDate(subMonths(today, 12), startOfMonth, hass.locale, hass.config),
calcDate(subMonths(today, 1), endOfMonth, hass.locale, hass.config),
calcDate(today, subMonths, hass.locale, hass.config, 12),
calcDate(today, subMonths, hass.locale, hass.config, 0),
];
case "now-1h":
return [

View File

@@ -0,0 +1,53 @@
/**
* ES5-compatible implementation of the keyed directive.
* Based on lit-html's keyed directive but written to avoid ES5 minification issues.
*
* This implementation avoids parameter destructuring in the update() method,
* which causes Terser with ecma: 5 to generate invalid references like `_k`.
*
* Used only for ES5 builds (legacy browsers). Modern builds use the original
* lit-html keyed directive.
*
* @see https://github.com/home-assistant/frontend/issues/28732
*/
// eslint-disable-next-line import/extensions
import { directive, Directive } from "lit-html/directive.js";
// eslint-disable-next-line import/extensions
import { setCommittedValue } from "lit-html/directive-helpers.js";
// eslint-disable-next-line lit/no-legacy-imports
import { nothing } from "lit-html";
// eslint-disable-next-line import/extensions
import type { Part } from "lit-html/directive.js";
class KeyedES5 extends Directive {
private _key: unknown = nothing;
render(k: unknown, v: unknown) {
this._key = k;
return v;
}
update(part: unknown, args: [unknown, unknown]) {
const k = args[0];
const v = args[1];
if (k !== this._key) {
// Clear the part before returning a value. The one-arg form of
// setCommittedValue sets the value to a sentinel which forces a
// commit the next render.
setCommittedValue(part as Part);
this._key = k;
}
return v;
}
}
/**
* Associates a renderable value with a unique key. When the key changes, the
* previous DOM is removed and disposed before rendering the next value, even
* if the value - such as a template - is the same.
*
* This is useful for forcing re-renders of stateful components, or working
* with code that expects new data to generate new HTML elements, such as some
* animation techniques.
*/
export const keyed = directive(KeyedES5);

View File

@@ -1,6 +1,16 @@
// From https://github.com/epoberezkin/fast-deep-equal
// MIT License - Copyright (c) 2017 Evgeny Poberezkin
export const deepEqual = (a: any, b: any): boolean => {
interface DeepEqualOptions {
/** Compare Symbol properties in addition to string keys */
compareSymbols?: boolean;
}
export const deepEqual = (
a: any,
b: any,
options?: DeepEqualOptions
): boolean => {
if (a === b) {
return true;
}
@@ -18,7 +28,7 @@ export const deepEqual = (a: any, b: any): boolean => {
return false;
}
for (i = length; i-- !== 0; ) {
if (!deepEqual(a[i], b[i])) {
if (!deepEqual(a[i], b[i], options)) {
return false;
}
}
@@ -35,7 +45,7 @@ export const deepEqual = (a: any, b: any): boolean => {
}
}
for (i of a.entries()) {
if (!deepEqual(i[1], b.get(i[0]))) {
if (!deepEqual(i[1], b.get(i[0]), options)) {
return false;
}
}
@@ -93,11 +103,28 @@ export const deepEqual = (a: any, b: any): boolean => {
for (i = length; i-- !== 0; ) {
const key = keys[i];
if (!deepEqual(a[key], b[key])) {
if (!deepEqual(a[key], b[key], options)) {
return false;
}
}
// Compare Symbol properties if requested
if (options?.compareSymbols) {
const symbolsA = Object.getOwnPropertySymbols(a);
const symbolsB = Object.getOwnPropertySymbols(b);
if (symbolsA.length !== symbolsB.length) {
return false;
}
for (const sym of symbolsA) {
if (!Object.prototype.hasOwnProperty.call(b, sym)) {
return false;
}
if (!deepEqual(a[sym], b[sym], options)) {
return false;
}
}
}
return true;
}

View File

@@ -21,6 +21,7 @@ import { measureTextWidth } from "../../util/text";
import { fireEvent } from "../../common/dom/fire_event";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { filterXSS } from "../../common/util/xss";
const safeParseFloat = (value) => {
const parsed = parseFloat(value);
@@ -184,7 +185,7 @@ export class StateHistoryChartLine extends LitElement {
}
if (param.seriesName) {
return `${param.marker} ${param.seriesName}: ${value}`;
return `${param.marker} ${filterXSS(param.seriesName)}: ${value}`;
}
return `${param.marker} ${value}`;
})

View File

@@ -1364,6 +1364,9 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell > * {
transition: var(--float-start) 0.2s ease;
}
.mdc-data-table__header-cell--numeric > span {
transition: none;
}
.mdc-data-table__header-cell ha-svg-icon {
top: -3px;
position: absolute;

View File

@@ -1,8 +1,9 @@
import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { fullEntitiesContext } from "../../data/context";
import type { DeviceAutomation } from "../../data/device/device_automation";
import {
@@ -11,11 +12,12 @@ import {
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
import "../ha-generic-picker";
import "../ha-md-select";
import "../ha-md-select-option";
import type { PickerValueRenderer } from "../ha-picker-field";
const NO_AUTOMATION_KEY = "NO_AUTOMATION";
const UNKNOWN_AUTOMATION_KEY = "UNKNOWN_AUTOMATION";
export abstract class HaDeviceAutomationPicker<
T extends DeviceAutomation,
@@ -28,7 +30,7 @@ export abstract class HaDeviceAutomationPicker<
@property({ type: Object }) public value?: T;
@state() private _automations: T[] = [];
@state() private _automations?: T[];
// Trigger an empty render so we start with a clean DOM.
// paper-listbox does not like changing things around.
@@ -44,12 +46,6 @@ export abstract class HaDeviceAutomationPicker<
);
}
protected get UNKNOWN_AUTOMATION_TEXT() {
return this.hass.localize(
"ui.panel.config.devices.automation.actions.unknown_action"
);
}
private _localizeDeviceAutomation: (
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
@@ -75,7 +71,7 @@ export abstract class HaDeviceAutomationPicker<
}
private get _value() {
if (!this.value) {
if (!this.value || !this._automations) {
return "";
}
@@ -88,7 +84,7 @@ export abstract class HaDeviceAutomationPicker<
);
if (idx === -1) {
return UNKNOWN_AUTOMATION_KEY;
return this.value.alias || this.value.type || "unknown";
}
return `${this._automations[idx].device_id}_${idx}`;
@@ -99,37 +95,21 @@ export abstract class HaDeviceAutomationPicker<
return nothing;
}
const value = this._value;
return html`
<ha-md-select
.label=${this.label}
.value=${value}
@change=${this._automationChanged}
@closed=${stopPropagation}
.disabled=${this._automations.length === 0}
>
${value === NO_AUTOMATION_KEY
? html`<ha-md-select-option .value=${NO_AUTOMATION_KEY}>
${this.NO_AUTOMATION_TEXT}
</ha-md-select-option>`
: nothing}
${value === UNKNOWN_AUTOMATION_KEY
? html`<ha-md-select-option .value=${UNKNOWN_AUTOMATION_KEY}>
${this.UNKNOWN_AUTOMATION_TEXT}
</ha-md-select-option>`
: nothing}
${this._automations.map(
(automation, idx) => html`
<ha-md-select-option .value=${`${automation.device_id}_${idx}`}>
${this._localizeDeviceAutomation(
this.hass,
this._entityReg,
automation
)}
</ha-md-select-option>
`
)}
</ha-md-select>
`;
return html`<ha-generic-picker
.hass=${this.hass}
.label=${this.label}
.value=${value}
.disabled=${!this._automations || this._automations.length === 0}
.getItems=${this._getItems(value, this._automations)}
@value-changed=${this._automationChanged}
.valueRenderer=${this._valueRenderer}
.unknownItemText=${this.hass.localize(
"ui.panel.config.devices.automation.actions.unknown_action"
)}
hide-clear-icon
>
</ha-generic-picker>`;
}
protected updated(changedProps) {
@@ -140,6 +120,57 @@ export abstract class HaDeviceAutomationPicker<
}
}
private _getItems = memoizeOne(
(value: string, automations: T[] | undefined) => {
if (!automations) {
return () => undefined;
}
const automationListItems = automations.map((automation, idx) => {
const primary = this._localizeDeviceAutomation(
this.hass,
this._entityReg,
automation
);
return {
id: `${automation.device_id}_${idx}`,
primary,
};
});
automationListItems.sort((a, b) =>
caseInsensitiveStringCompare(
a.primary,
b.primary,
this.hass.locale.language
)
);
if (value === NO_AUTOMATION_KEY) {
automationListItems.unshift({
id: NO_AUTOMATION_KEY,
primary: this.NO_AUTOMATION_TEXT,
});
}
return () => automationListItems;
}
);
private _valueRenderer: PickerValueRenderer = (value: string) => {
const automation = this._automations?.find(
(a, idx) => value === `${a.device_id}_${idx}`
);
const text = automation
? this._localizeDeviceAutomation(this.hass, this._entityReg, automation)
: value === NO_AUTOMATION_KEY
? this.NO_AUTOMATION_TEXT
: value;
return html`<span slot="headline">${text}</span>`;
};
private async _updateDeviceInfo() {
this._automations = this.deviceId
? (await this._fetchDeviceAutomations(this.hass, this.deviceId)).sort(
@@ -161,13 +192,14 @@ export abstract class HaDeviceAutomationPicker<
this._renderEmpty = false;
}
private _automationChanged(ev) {
const value = ev.target.value;
if (!value || [UNKNOWN_AUTOMATION_KEY, NO_AUTOMATION_KEY].includes(value)) {
private _automationChanged(ev: CustomEvent<{ value: string }>) {
ev.stopPropagation();
const value = ev.detail.value;
if (!value || NO_AUTOMATION_KEY === value) {
return;
}
const [deviceId, idx] = value.split("_");
const automation = this._automations[idx];
const automation = this._automations![idx];
if (automation.device_id !== deviceId) {
return;
}

View File

@@ -18,6 +18,7 @@ import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
export type HaDevicePickerDeviceFilterFunc = (
device: DeviceRegistryEntry
@@ -94,7 +95,30 @@ export class HaDevicePicker extends LitElement {
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
private _getDevicesMemoized = memoizeOne(getDevices);
private _getDevicesMemoized = memoizeOne(
(
_devices: HomeAssistant["devices"],
configEntryLookup: Record<string, ConfigEntry>,
includeDomains?: string[],
excludeDomains?: string[],
includeDeviceClasses?: string[],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc,
excludeDevices?: string[],
value?: string
) =>
getDevices(
this.hass,
configEntryLookup,
includeDomains,
excludeDomains,
includeDeviceClasses,
deviceFilter,
entityFilter,
excludeDevices,
value
)
);
protected firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
@@ -110,7 +134,7 @@ export class HaDevicePicker extends LitElement {
private _getItems = () =>
this._getDevicesMemoized(
this.hass,
this.hass.devices,
this._configEntryLookup,
this.includeDomains,
this.excludeDomains,

View File

@@ -275,6 +275,11 @@ export class HaEntityNamePicker extends LitElement {
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
const value = this._items[idx];
// Pre-fill the field value when editing a text item
if (value.type === "text" && value.text) {
this._picker?.setFieldValue(value.text);
}
}
private get _items(): EntityNameItem[] {

View File

@@ -143,17 +143,19 @@ export class HaEntityToggle extends LitElement {
// Optimistic update.
this._isOn = turnOn;
await this.hass.callService(serviceDomain, service, {
entity_id: this.stateObj.entity_id,
});
setTimeout(async () => {
// If after 2 seconds we have not received a state update
// reset the switch to it's original state.
if (this.stateObj === currentState) {
this._isOn = isOn(this.stateObj);
}
}, 2000);
try {
await this.hass.callService(serviceDomain, service, {
entity_id: this.stateObj.entity_id,
});
} finally {
setTimeout(async () => {
// If after 2 seconds we have not received a state update
// reset the switch to it's original state.
if (this.stateObj === currentState) {
this._isOn = isOn(this.stateObj);
}
}, 2000);
}
}
static styles = css`

View File

@@ -174,12 +174,14 @@ export class HaAutomationRow extends LitElement {
}
::slotted([slot="header"]) {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
margin: 0 var(--ha-space-3);
}
.icons {
display: flex;
align-items: center;
flex-shrink: 0;
}
:host([sort-selected]) .row {
outline: solid;

View File

@@ -51,7 +51,10 @@ export class HaCard extends LitElement {
font-weight: var(--ha-font-weight-normal);
}
:host ::slotted(.card-content:not(:first-child)),
:host
::slotted(
.card-content:not(:nth-child(1 of .card-content, .card-header))
),
slot:not(:first-child)::slotted(.card-content) {
padding-top: 0;
margin-top: calc(var(--ha-space-2) * -1);

View File

@@ -255,6 +255,7 @@ export class HaCodeEditor extends ReactiveElement {
...this._loadedCodeMirror.tabKeyBindings,
saveKeyBinding,
]),
this._loadedCodeMirror.search({ top: true }),
this._loadedCodeMirror.langCompartment.of(this._mode),
this._loadedCodeMirror.haTheme,
this._loadedCodeMirror.haSyntaxHighlighting,

View File

@@ -1,24 +0,0 @@
import type { PropertyValues } from "lit";
import { customElement, property } from "lit/decorators";
import { HaTextField } from "./ha-textfield";
@customElement("ha-combo-box-textfield")
export class HaComboBoxTextField extends HaTextField {
@property({ type: Boolean, attribute: "force-blank-value" })
public forceBlankValue = false;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("value") || changedProps.has("forceBlankValue")) {
if (this.forceBlankValue && this.value) {
this.value = "";
}
}
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-combo-box-textfield": HaComboBoxTextField;
}
}

View File

@@ -1,9 +1,11 @@
import { mdiMinusThick, mdiPlusThick } from "@mdi/js";
import type { TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import "./ha-base-time-input";
import type { TimeChangedEvent } from "./ha-base-time-input";
import "./ha-button-toggle-group";
export interface HaDurationData {
days?: number;
@@ -13,6 +15,8 @@ export interface HaDurationData {
milliseconds?: number;
}
const FIELDS = ["milliseconds", "seconds", "minutes", "hours", "days"];
@customElement("ha-duration-input")
class HaDurationInput extends LitElement {
@property({ attribute: false }) public data?: HaDurationData;
@@ -29,41 +33,80 @@ class HaDurationInput extends LitElement {
@property({ attribute: "enable-day", type: Boolean })
public enableDay = false;
@property({ attribute: "allow-negative", type: Boolean })
public allowNegative = false;
@property({ type: Boolean }) public disabled = false;
private _toggleNegative = false;
protected render(): TemplateResult {
return html`
<ha-base-time-input
.label=${this.label}
.helper=${this.helper}
.required=${this.required}
.clearable=${!this.required && this.data !== undefined}
.autoValidate=${this.required}
.disabled=${this.disabled}
errorMessage="Required"
enable-second
.enableMillisecond=${this.enableMillisecond}
.enableDay=${this.enableDay}
format="24"
.days=${this._days}
.hours=${this._hours}
.minutes=${this._minutes}
.seconds=${this._seconds}
.milliseconds=${this._milliseconds}
@value-changed=${this._durationChanged}
no-hours-limit
day-label="dd"
hour-label="hh"
min-label="mm"
sec-label="ss"
ms-label="ms"
></ha-base-time-input>
<div class="row">
${this.allowNegative
? html`
<ha-button-toggle-group
size="small"
.buttons=${[
{ label: "+", iconPath: mdiPlusThick, value: "+" },
{ label: "-", iconPath: mdiMinusThick, value: "-" },
]}
.active=${this._negative ? "-" : "+"}
@value-changed=${this._negativeChanged}
></ha-button-toggle-group>
`
: nothing}
<ha-base-time-input
.label=${this.label}
.helper=${this.helper}
.required=${this.required}
.clearable=${!this.required && this.data !== undefined}
.autoValidate=${this.required}
.disabled=${this.disabled}
errorMessage="Required"
enable-second
.enableMillisecond=${this.enableMillisecond}
.enableDay=${this.enableDay}
format="24"
.days=${this._days}
.hours=${this._hours}
.minutes=${this._minutes}
.seconds=${this._seconds}
.milliseconds=${this._milliseconds}
@value-changed=${this._durationChanged}
no-hours-limit
day-label="dd"
hour-label="hh"
min-label="mm"
sec-label="ss"
ms-label="ms"
></ha-base-time-input>
</div>
`;
}
private get _negative() {
return (
this._toggleNegative ||
(this.data?.days
? this.data.days < 0
: this.data?.hours
? this.data.hours < 0
: this.data?.minutes
? this.data.minutes < 0
: this.data?.seconds
? this.data.seconds < 0
: this.data?.milliseconds
? this.data.milliseconds < 0
: false)
);
}
private get _days() {
return this.data?.days
? Number(this.data.days)
? this.allowNegative
? Math.abs(Number(this.data.days))
: Number(this.data.days)
: this.required || this.data
? 0
: NaN;
@@ -71,7 +114,9 @@ class HaDurationInput extends LitElement {
private get _hours() {
return this.data?.hours
? Number(this.data.hours)
? this.allowNegative
? Math.abs(Number(this.data.hours))
: Number(this.data.hours)
: this.required || this.data
? 0
: NaN;
@@ -79,7 +124,9 @@ class HaDurationInput extends LitElement {
private get _minutes() {
return this.data?.minutes
? Number(this.data.minutes)
? this.allowNegative
? Math.abs(Number(this.data.minutes))
: Number(this.data.minutes)
: this.required || this.data
? 0
: NaN;
@@ -87,7 +134,9 @@ class HaDurationInput extends LitElement {
private get _seconds() {
return this.data?.seconds
? Number(this.data.seconds)
? this.allowNegative
? Math.abs(Number(this.data.seconds))
: Number(this.data.seconds)
: this.required || this.data
? 0
: NaN;
@@ -95,7 +144,9 @@ class HaDurationInput extends LitElement {
private get _milliseconds() {
return this.data?.milliseconds
? Number(this.data.milliseconds)
? this.allowNegative
? Math.abs(Number(this.data.milliseconds))
: Number(this.data.milliseconds)
: this.required || this.data
? 0
: NaN;
@@ -113,6 +164,14 @@ class HaDurationInput extends LitElement {
if ("days" in value) value.days ||= 0;
if ("milliseconds" in value) value.milliseconds ||= 0;
if (this.allowNegative) {
FIELDS.forEach((t) => {
if (value[t]) {
value[t] = Math.abs(value[t]);
}
});
}
if (!this.enableMillisecond && !value.milliseconds) {
// @ts-ignore
delete value.milliseconds;
@@ -135,12 +194,47 @@ class HaDurationInput extends LitElement {
value.days = (value.days ?? 0) + Math.floor(value.hours / 24);
value.hours %= 24;
}
if (this._negative) {
FIELDS.forEach((t) => {
if (value[t]) {
value[t] = -Math.abs(value[t]);
}
});
}
}
fireEvent(this, "value-changed", {
value,
});
}
private _negativeChanged(ev) {
ev.stopPropagation();
const negative = (ev.detail?.value || ev.target.value) === "-";
this._toggleNegative = negative;
const value = this.data;
if (value) {
FIELDS.forEach((t) => {
if (value[t]) {
value[t] = negative ? -Math.abs(value[t]) : Math.abs(value[t]);
}
});
fireEvent(this, "value-changed", {
value,
});
}
}
static styles = css`
.row {
display: flex;
align-items: center;
}
ha-button-toggle-group {
margin: var(--ha-space-2);
}
`;
}
declare global {

View File

@@ -0,0 +1,198 @@
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-label";
import "./ha-list";
import "./ha-list-item";
import "./voice-assistant-brand-icon";
import { voiceAssistants } from "../data/expose";
import "../panels/config/voice-assistants/expose/expose-assistant-icon";
@customElement("ha-filter-voice-assistants")
export class HaFilterVoiceAssistants extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
// the list of selected voiceAssistantIds
@property({ attribute: false }) public value: string[] = [];
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _voiceAssistantOptions: string[] = [];
@state() private _shouldRender = false;
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize(
"ui.panel.config.dashboard.voice_assistants.main"
)}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
></ha-icon-button>`
: nothing}
</div>
${this._shouldRender
? html`<ha-list
@selected=${this._assistantsSelected}
class="ha-scrollbar"
multi
>
${repeat(
this._voiceAssistantOptions,
(voiceAssistantId) => voiceAssistantId,
(voiceAssistantId) =>
html`<ha-check-list-item
.value=${voiceAssistantId}
.selected=${(this.value || []).includes(voiceAssistantId)}
hasMeta
graphic="icon"
>
<voice-assistant-brand-icon
slot="graphic"
.voiceAssistantId=${voiceAssistantId}
.hass=${this.hass}
>
</voice-assistant-brand-icon>
${voiceAssistants[voiceAssistantId].name}
</ha-check-list-item>`
)}
</ha-list> `
: nothing}
</ha-expansion-panel>
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
this._voiceAssistantOptions = Object.keys(voiceAssistants);
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("ha-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private async _assistantsSelected(
ev: CustomEvent<SelectedDetail<Set<number>>>
) {
if (!ev.detail.index) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const newvalue: string[] = [];
for (const index of ev.detail.index) {
newvalue.push(this._voiceAssistantOptions![index]);
}
this.value = newvalue;
fireEvent(this, "data-table-filter-changed", {
value: this.value,
items: undefined,
});
}
private _clearFilter(ev) {
ev.preventDefault();
this.value = [];
fireEvent(this, "data-table-filter-changed", {
value: undefined,
items: undefined,
});
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
position: relative;
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
.add {
position: absolute;
bottom: 0;
right: 0;
left: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-voice-assistants": HaFilterVoiceAssistants;
}
}

View File

@@ -1,5 +1,21 @@
import type { Selector } from "../../data/selector";
import type { HaFormSchema } from "./types";
import type { HaFormData, HaFormSchema } from "./types";
const setDefaultValue = (
field: HaFormSchema,
value: HaFormData | undefined
) => {
if ("selector" in field && "choose" in field.selector) {
const firstChoice = Object.keys(field.selector.choose.choices)[0];
if (firstChoice) {
return {
active_choice: firstChoice,
[firstChoice]: value,
};
}
}
return value;
};
export const computeInitialHaFormData = (
schema: HaFormSchema[] | readonly HaFormSchema[]
@@ -10,9 +26,12 @@ export const computeInitialHaFormData = (
field.description?.suggested_value !== undefined &&
field.description?.suggested_value !== null
) {
data[field.name] = field.description.suggested_value;
data[field.name] = setDefaultValue(
field,
field.description.suggested_value
);
} else if ("default" in field) {
data[field.name] = field.default;
data[field.name] = setDefaultValue(field, field.default);
} else if (field.type === "expandable") {
const expandableData = computeInitialHaFormData(field.schema);
if (field.required || Object.keys(expandableData).length) {
@@ -108,6 +127,21 @@ export const computeInitialHaFormData = (
data[field.name] = {};
} else if ("state" in selector) {
data[field.name] = selector.state?.multiple ? [] : "";
} else if ("choose" in selector) {
const firstChoice = Object.keys(selector.choose.choices)[0];
if (!firstChoice) {
data[field.name] = {};
} else {
data[field.name] = {
active_choice: firstChoice,
[firstChoice]: computeInitialHaFormData([
{
name: firstChoice,
selector: selector.choose.choices[firstChoice].selector,
},
])[firstChoice],
};
}
} else {
throw new Error(
`Selector ${Object.keys(selector)[0]} not supported in initial form data`

View File

@@ -1,12 +1,19 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiPlaylistPlus } from "@mdi/js";
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import {
css,
html,
LitElement,
nothing,
type CSSResultGroup,
type PropertyValues,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import { throttle } from "../common/util/throttle";
import { PickerMixin } from "../mixins/picker-mixin";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
@@ -114,6 +121,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@state() private _openedNarrow = false;
@state() private _unknownValue = false;
static shadowRootOptions = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
@@ -130,6 +139,25 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _unsubscribeTinyKeys?: () => void;
protected willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has("value")) {
this._setUnknownValue();
return;
}
if (changedProperties.has("hass")) {
this._throttleUnknownValue();
}
}
public setFieldValue(value: string) {
if (this._comboBox) {
this._comboBox.setFieldValue(value);
return;
}
// Store initial value to set when opened
this._initialFieldValue = value;
}
protected render() {
// Only show label if it's not a top label and there is a value.
const label = this.useTopLabel && this.value ? undefined : this.label;
@@ -157,11 +185,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
type="button"
class=${this._opened ? "opened" : ""}
compact
.unknown=${this._unknownValue(
this.allowCustomValue,
this.value,
this.getItems()
)}
.unknown=${this._unknownValue}
.unknownItemText=${this.unknownItemText}
aria-label=${ifDefined(this.label)}
@click=${this.open}
@@ -182,40 +206,42 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
</ha-picker-field>`}
</slot>
</div>
${!this._openedNarrow && (this._pickerWrapperOpen || this._opened)
? html`
<wa-popover
.open=${this._pickerWrapperOpen}
style="--body-width: ${this._popoverWidth}px;"
without-arrow
distance="-4"
.placement=${this.popoverPlacement}
for="picker"
auto-size="vertical"
auto-size-padding="16"
@wa-after-show=${this._dialogOpened}
@wa-after-hide=${this._hidePicker}
trap-focus
role="dialog"
aria-modal="true"
aria-label=${this.label || "Select option"}
>
${this._renderComboBox()}
</wa-popover>
`
: this._pickerWrapperOpen || this._opened
? html`<ha-bottom-sheet
flexcontent
.open=${this._pickerWrapperOpen}
@wa-after-show=${this._dialogOpened}
@closed=${this._hidePicker}
role="dialog"
aria-modal="true"
aria-label=${this.label || "Select option"}
>
${this._renderComboBox(true)}
</ha-bottom-sheet>`
: nothing}
${this._pickerWrapperOpen || this._opened
? this._openedNarrow
? html`
<ha-bottom-sheet
flexcontent
.open=${this._pickerWrapperOpen}
@wa-after-show=${this._dialogOpened}
@closed=${this._hidePicker}
role="dialog"
aria-modal="true"
aria-label=${this.label || "Select option"}
>
${this._renderComboBox(true)}
</ha-bottom-sheet>
`
: html`
<wa-popover
.open=${this._pickerWrapperOpen}
style="--body-width: ${this._popoverWidth}px;"
without-arrow
distance="-4"
.placement=${this.popoverPlacement}
for="picker"
auto-size="vertical"
auto-size-padding="16"
@wa-after-show=${this._dialogOpened}
@wa-after-hide=${this._hidePicker}
trap-focus
role="dialog"
aria-modal="true"
aria-label=${this.label || "Select option"}
>
${this._renderComboBox()}
</wa-popover>
`
: nothing}
</div>
${this._renderHelper()}`;
}
@@ -248,26 +274,29 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
`;
}
private _unknownValue = memoizeOne(
(
allowCustomValue: boolean,
value?: string,
items?: (PickerComboBoxItem | string)[]
) => {
if (
allowCustomValue ||
value === undefined ||
value === null ||
value === "" ||
!items
) {
return false;
}
return !items.some(
(item) => typeof item !== "string" && item.id === value
);
private _setUnknownValue = () => {
const items = this.getItems();
if (
this.allowCustomValue ||
this.value === undefined ||
this.value === null ||
this.value === "" ||
!items
) {
this._unknownValue = false;
return;
}
this._unknownValue = !items.some(
(item) => typeof item !== "string" && item.id === this.value
);
};
private _throttleUnknownValue = throttle(
this._setUnknownValue,
1000,
true,
false
);
private _renderHelper() {
@@ -283,9 +312,16 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
</ha-input-helper-text>`;
}
private _initialFieldValue?: string;
private _dialogOpened = () => {
this._opened = true;
requestAnimationFrame(() => {
// Set initial field value if needed
if (this._initialFieldValue) {
this._comboBox?.setFieldValue(this._initialFieldValue);
this._initialFieldValue = undefined;
}
if (this.hass && isIosApp(this.hass)) {
this.hass.auth.external!.fireMessage({
type: "focus_element",
@@ -295,6 +331,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
});
return;
}
this._comboBox?.focus();
});
};
@@ -376,6 +413,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
.container {
position: relative;
display: block;
max-width: 100%;
}
label[disabled] {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.6));

View File

@@ -1,4 +1,4 @@
import { mdiLabel, mdiPlus } from "@mdi/js";
import { mdiPlus } from "@mdi/js";
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
@@ -25,11 +25,9 @@ import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon";
const ADD_NEW_ID = "___ADD_NEW___";
const NO_LABELS = "___NO_LABELS___";
@customElement("ha-label-picker")
export class HaLabelPicker extends SubscribeMixin(LitElement) {
@@ -108,52 +106,10 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
];
}
private _labelMap = memoizeOne(
(
labels: LabelRegistryEntry[] | undefined
): Map<string, LabelRegistryEntry> => {
if (!labels) {
return new Map();
}
return new Map(labels.map((label) => [label.label_id, label]));
}
);
private _computeValueRenderer = memoizeOne(
(labels: LabelRegistryEntry[] | undefined): PickerValueRenderer =>
(value) => {
const label = this._labelMap(labels).get(value);
if (!label) {
return html`
<ha-svg-icon slot="start" .path=${mdiLabel}></ha-svg-icon>
<span slot="headline">${value}</span>
`;
}
return html`
${label.icon
? html`<ha-icon slot="start" .icon=${label.icon}></ha-icon>`
: html`<ha-svg-icon slot="start" .path=${mdiLabel}></ha-svg-icon>`}
<span slot="headline">${label.name}</span>
`;
}
);
private _getLabelsMemoized = memoizeOne(getLabels);
private _getItems = () => {
if (!this._labels || this._labels.length === 0) {
return [
{
id: NO_LABELS,
primary: this.hass.localize("ui.components.label-picker.no_labels"),
icon_path: mdiLabel,
},
];
}
return this._getLabelsMemoized(
private _getItems = () =>
this._getLabelsMemoized(
this.hass.states,
this.hass.areas,
this.hass.devices,
@@ -166,7 +122,6 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
this.entityFilter,
this.excludeLabels
);
};
private _allLabelNames = memoizeOne((labels?: LabelRegistryEntry[]) => {
if (!labels) {
@@ -219,8 +174,6 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
this.placeholder ??
this.hass.localize("ui.components.label-picker.label");
const valueRenderer = this._computeValueRenderer(this._labels);
return html`
<ha-generic-picker
.disabled=${this.disabled}
@@ -237,7 +190,6 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
.value=${this.value}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
.searchKeys=${labelComboBoxKeys}
@value-changed=${this._valueChanged}
>
@@ -251,10 +203,6 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
const value = ev.detail.value;
if (value === NO_LABELS) {
return;
}
if (!value) {
this._setValue(undefined);
return;

View File

@@ -153,6 +153,12 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@state() private _items: PickerComboBoxItem[] = [];
public setFieldValue(value: string) {
if (this._searchFieldElement) {
this._searchFieldElement.value = value;
}
}
protected get scrollableElement(): HTMLElement | null {
return this._virtualizerElement as HTMLElement | null;
}
@@ -787,7 +793,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
.section-title,
.title {
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-1) var(--ha-space-2);
padding: var(--ha-space-2) var(--ha-space-3);
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
min-height: var(--ha-space-6);

View File

@@ -10,7 +10,7 @@ class HaSectionTitle extends LitElement {
static styles = css`
:host {
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-1) var(--ha-space-2);
padding: var(--ha-space-2) var(--ha-space-3);
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
min-height: var(--ha-space-6);

View File

@@ -38,6 +38,13 @@ export class HaChooseSelector extends LitElement {
) {
this._setActiveChoice();
}
if (
changedProperties.has("value") &&
changedProperties.get("value")?.active_choice &&
changedProperties.get("value")?.active_choice !== this._activeChoice
) {
this._setActiveChoice();
}
}
protected render() {

View File

@@ -1,3 +1,4 @@
import memoizeOne from "memoize-one";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { DurationSelector } from "../../data/selector";
@@ -11,7 +12,10 @@ export class HaTimeDuration extends LitElement {
@property({ attribute: false }) public selector!: DurationSelector;
@property({ attribute: false }) public value?: HaDurationData;
@property({ attribute: false }) public value?:
| HaDurationData
| string
| number;
@property() public label?: string;
@@ -21,16 +25,47 @@ export class HaTimeDuration extends LitElement {
@property({ type: Boolean }) public required = true;
private _data = memoizeOne(
(value?: HaDurationData | string | number): HaDurationData | undefined => {
if (typeof value === "number") {
return { seconds: value };
}
if (typeof value === "string") {
const negative = value.trim()[0] === "-";
const parts = value
.split(":")
.map((p) => (negative && p ? -Math.abs(Number(p)) : Number(p)));
if (parts.length === 1) {
return { seconds: parts[0] };
}
if (parts.length === 2) {
return { hours: parts[0], minutes: parts[1] };
}
if (parts.length === 3) {
return {
hours: parts[0],
minutes: parts[1],
seconds: parts[2],
};
}
return undefined;
}
return value;
}
);
protected render() {
return html`
<ha-duration-input
.label=${this.label}
.helper=${this.helper}
.data=${this.value}
.data=${this._data(this.value)}
.disabled=${this.disabled}
.required=${this.required}
.enableDay=${this.selector.duration?.enable_day}
.enableMillisecond=${this.selector.duration?.enable_millisecond}
.allowNegative=${this.selector.duration?.allow_negative}
></ha-duration-input>
`;
}

View File

@@ -52,8 +52,6 @@ import "./ha-spinner";
import "./ha-svg-icon";
import "./user/ha-user-badge";
const SUPPORT_SCROLL_IF_NEEDED = "scrollIntoViewIfNeeded" in document.body;
const SORT_VALUE_URL_PATHS = {
energy: 1,
map: 2,
@@ -344,17 +342,6 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
this._calculateCounts();
if (!SUPPORT_SCROLL_IF_NEEDED) {
return;
}
if (oldHass?.panelUrl !== this.hass.panelUrl) {
const selectedEl = this.shadowRoot!.querySelector(".selected");
if (selectedEl) {
// @ts-ignore
selectedEl.scrollIntoViewIfNeeded();
}
}
}
private _calculateCounts = throttle(() => {

View File

@@ -57,6 +57,7 @@ export class HaSlider extends Slider {
#thumb {
border: none;
background-color: var(--ha-slider-thumb-color, var(--primary-color));
overflow: hidden;
}
#thumb:after {

View File

@@ -1,4 +1,5 @@
import "@home-assistant/webawesome/dist/components/dialog/dialog";
import type WaDialog from "@home-assistant/webawesome/dist/components/dialog/dialog";
import { mdiClose } from "@mdi/js";
import { css, html, LitElement } from "lit";
import {
@@ -49,7 +50,6 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
* @cssprop --ha-dialog-hide-duration - Hide animation duration.
* @cssprop --ha-dialog-surface-background - Dialog background color.
* @cssprop --ha-dialog-border-radius - Border radius of the dialog surface.
* @cssprop --dialog-z-index - Z-index for the dialog.
* @cssprop --dialog-surface-margin-top - Top margin for the dialog surface.
*
* @attr {boolean} open - Controls the dialog open state.
@@ -114,6 +114,8 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
@state()
private _bodyScrolled = false;
private _escapePressed = false;
protected get scrollableElement(): HTMLElement | null {
return this.bodyContainer;
}
@@ -139,6 +141,8 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
(this.headerTitle !== undefined ? "ha-wa-dialog-title" : undefined)
)}
aria-describedby=${ifDefined(this.ariaDescribedBy)}
@keydown=${this._handleKeyDown}
@wa-hide=${this._handleHide}
@wa-show=${this._handleShow}
@wa-after-show=${this._handleAfterShow}
@wa-after-hide=${this._handleAfterHide}
@@ -208,9 +212,11 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
fireEvent(this, "after-show");
};
private _handleAfterHide = () => {
this._open = false;
fireEvent(this, "closed");
private _handleAfterHide = (ev: CustomEvent<{ source: Element }>) => {
if (ev.eventPhase === Event.AT_TARGET) {
this._open = false;
fireEvent(this, "closed");
}
};
public disconnectedCallback(): void {
@@ -223,6 +229,23 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
this._bodyScrolled = (ev.target as HTMLDivElement).scrollTop > 0;
}
private _handleKeyDown(ev: KeyboardEvent) {
if (ev.key === "Escape") {
this._escapePressed = true;
}
}
private _handleHide(ev: CustomEvent<{ source: Element }>) {
if (
this.preventScrimClose &&
this._escapePressed &&
ev.detail.source === (ev.target as WaDialog).dialog
) {
ev.preventDefault();
}
this._escapePressed = false;
}
static get styles() {
return [
...super.styles,

View File

@@ -24,6 +24,7 @@ import { setupLeafletMap } from "../../common/dom/setup-leaflet-map";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { DecoratedMarker } from "../../common/map/decorated_marker";
import { filterXSS } from "../../common/util/xss";
import type { HomeAssistant, ThemeMode } from "../../types";
import { isTouch } from "../../util/is_touch";
import "../ha-icon-button";
@@ -381,7 +382,7 @@ export class HaMap extends ReactiveElement {
this.hass.config
);
}
return `${path.name}<br>${formattedTime}`;
return `${filterXSS(path.name ?? "")}<br>${formattedTime}`;
}
private _drawPaths(): void {
@@ -549,7 +550,7 @@ export class HaMap extends ReactiveElement {
iconHTML = el.outerHTML;
} else {
const el = document.createElement("span");
el.innerHTML = title;
el.textContent = title;
iconHTML = el.outerHTML;
}

View File

@@ -1,7 +1,6 @@
import type { ActionDetail } from "@material/mwc-list";
import {
mdiAlphaABoxOutline,
mdiArrowLeft,
mdiClose,
mdiDotsVertical,
mdiGrid,
@@ -21,9 +20,10 @@ import type {
} from "../../data/media-player";
import { haStyleDialog, haStyleDialogFixedTop } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "../ha-dialog";
import "../ha-wa-dialog";
import "../ha-dialog-header";
import "../ha-list-item";
import "../ha-icon-button-arrow-prev";
import "./ha-media-manage-button";
import "./ha-media-player-browse";
import type {
@@ -44,6 +44,8 @@ class DialogMediaPlayerBrowse extends LitElement {
@state() _preferredLayout: MediaPlayerLayoutType = "auto";
@state() private _open = false;
@query("ha-media-player-browse") private _browser!: HaMediaPlayerBrowse;
public showDialog(params: MediaPlayerBrowseDialogParams): void {
@@ -54,9 +56,11 @@ class DialogMediaPlayerBrowse extends LitElement {
media_content_type: undefined,
},
];
this._open = true;
}
public closeDialog() {
this._open = false;
this._params = undefined;
this._navigateIds = undefined;
this._currentItem = undefined;
@@ -71,28 +75,20 @@ class DialogMediaPlayerBrowse extends LitElement {
}
return html`
<ha-dialog
open
scrimClickAction
escapeKeyAction
hideActions
flexContent
.heading=${!this._currentItem
? this.hass.localize(
"ui.components.media-browser.media-player-browser"
)
: this._currentItem.title}
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
flexcontent
@closed=${this.closeDialog}
@opened=${this._dialogOpened}
>
<ha-dialog-header show-border slot="heading">
<ha-dialog-header show-border slot="header">
${this._navigateIds.length > (this._params.minimumNavigateLevel ?? 1)
? html`
<ha-icon-button
<ha-icon-button-arrow-prev
slot="navigationIcon"
.path=${mdiArrowLeft}
@click=${this._goBack}
></ha-icon-button>
></ha-icon-button-arrow-prev>
`
: nothing}
<span slot="title">
@@ -153,7 +149,7 @@ class DialogMediaPlayerBrowse extends LitElement {
<ha-icon-button
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
dialogAction="close"
data-dialog="close"
slot="actionItems"
></ha-icon-button>
</ha-dialog-header>
@@ -173,7 +169,7 @@ class DialogMediaPlayerBrowse extends LitElement {
@media-picked=${this._mediaPicked}
@media-browsed=${this._mediaBrowsed}
></ha-media-player-browse>
</ha-dialog>
</ha-wa-dialog>
`;
}
@@ -225,8 +221,7 @@ class DialogMediaPlayerBrowse extends LitElement {
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-dialog {
--dialog-z-index: 9;
ha-wa-dialog {
--dialog-content-padding: 0;
}
@@ -241,9 +236,9 @@ class DialogMediaPlayerBrowse extends LitElement {
}
@media (min-width: 800px) {
ha-dialog {
--mdc-dialog-max-width: 800px;
--mdc-dialog-max-height: calc(
ha-wa-dialog {
--ha-dialog-max-width: 800px;
--ha-dialog-max-height: calc(
100vh - var(--ha-space-18) - var(--safe-area-inset-y)
);
}

View File

@@ -1,14 +1,15 @@
import { type CSSResultGroup, LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { mdiSpeaker } from "@mdi/js";
import { mdiSpeaker, mdiSpeakerPause, mdiSpeakerPlay } from "@mdi/js";
import memoizeOne from "memoize-one";
import type { HomeAssistant } from "../../types";
import { computeStateName } from "../../common/entity/compute_state_name";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeRTL } from "../../common/util/compute_rtl";
import { fireEvent } from "../../common/dom/fire_event";
import "../ha-switch";
import "../ha-svg-icon";
import type { MediaPlayerEntity } from "../../data/media-player";
@customElement("ha-media-player-toggle")
class HaMediaPlayerToggle extends LitElement {
@@ -20,15 +21,61 @@ class HaMediaPlayerToggle extends LitElement {
@property({ type: Boolean }) public disabled = false;
private _computeDisplayData = memoizeOne(
(
entityId: string,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
isRTL: boolean,
stateObj: HomeAssistant["states"][string]
) => {
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
entities,
devices,
areas,
floors
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return { primary, secondary };
}
);
protected render() {
const stateObj = this.hass.states[this.entityId];
let icon = mdiSpeaker;
if (stateObj.state === "playing") {
icon = mdiSpeakerPlay;
} else if (stateObj.state === "paused") {
icon = mdiSpeakerPause;
}
const isRTL = computeRTL(this.hass);
const { primary, secondary } = this._computeDisplayData(
this.entityId,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors,
isRTL,
stateObj
);
return html`<div class="list-item">
<ha-svg-icon .path=${mdiSpeaker}></ha-svg-icon>
<ha-svg-icon .path=${icon}></ha-svg-icon>
<div class="info">
<div class="main-text">${computeStateName(stateObj)}</div>
<div class="secondary-text">
${this._formatSecondaryText(stateObj as MediaPlayerEntity)}
</div>
<div class="main-text">${primary}</div>
<div class="secondary-text">${secondary}</div>
</div>
<ha-switch
.disabled=${this.disabled}
@@ -38,16 +85,6 @@ class HaMediaPlayerToggle extends LitElement {
</div>`;
}
private _formatSecondaryText(stateObj: MediaPlayerEntity): string {
if (stateObj.state !== "playing") {
return this.hass.localize("ui.card.media_player.idle");
}
return [stateObj.attributes.media_title, stateObj.attributes.media_artist]
.filter((segment) => segment)
.join(" · ");
}
static get styles(): CSSResultGroup {
return [
css`

View File

@@ -0,0 +1,51 @@
import { customElement, property } from "lit/decorators";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html } from "lit";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import { voiceAssistants } from "../data/expose";
import { brandsUrl } from "../util/brands-url";
@customElement("voice-assistant-brand-icon")
export class VoiceAssistantBrandicon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public voiceAssistantId!: string;
protected render() {
return html`
<img
class="logo"
alt=${voiceAssistants[this.voiceAssistantId].name}
src=${brandsUrl({
domain: voiceAssistants[this.voiceAssistantId].domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.logo {
position: relative;
height: 24px;
margin-right: 16px;
margin-inline-end: 16px;
margin-inline-start: initial;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"voice-assistant-brand-icon": VoiceAssistantBrandicon;
}
}

View File

@@ -449,16 +449,9 @@ const getEnergyData = async (
const allStatIDs = [...energyStatIds, ...waterStatIds, ...powerStatIds];
const dayDifference = differenceInDays(end || new Date(), start);
const period =
isFirstDayOfMonth(start) &&
(!end || isLastDayOfMonth(end)) &&
dayDifference > 35
? "month"
: dayDifference > 2
? "day"
: "hour";
const finePeriod =
dayDifference > 64 ? "day" : dayDifference > 8 ? "hour" : "5minute";
const period = getSuggestedPeriod(start, end);
const finePeriod = getSuggestedPeriod(start, end, true);
const statsMetadata: Record<string, StatisticsMetaData> = {};
const statsMetadataArray = allStatIDs.length
@@ -589,7 +582,7 @@ const getEnergyData = async (
consumptionStatIDs,
co2SignalEntity,
end,
dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour"
period
);
if (compare) {
_fossilEnergyConsumptionCompare = getFossilEnergyConsumption(
@@ -598,7 +591,7 @@ const getEnergyData = async (
consumptionStatIDs,
co2SignalEntity,
endCompare,
dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour"
period
);
}
}
@@ -1427,3 +1420,22 @@ export const formatPowerShort = (
units[unitIndex]
);
};
export function getSuggestedPeriod(
start: Date,
end?: Date,
fine = false
): "5minute" | "hour" | "day" | "month" {
const dayDifference = differenceInDays(end || new Date(), start);
if (fine) {
return dayDifference > 64 ? "day" : dayDifference > 8 ? "hour" : "5minute";
}
return isFirstDayOfMonth(start) &&
(!end || isLastDayOfMonth(end)) &&
dayDifference > 35
? "month"
: dayDifference > 2
? "day"
: "hour";
}

View File

@@ -69,6 +69,7 @@ export const DOMAIN_ATTRIBUTES_UNITS = {
current_humidity: "%",
min_humidity: "%",
max_humidity: "%",
target_humidity_step: "%",
},
light: {
color_temp: "mired",

View File

@@ -1,4 +1,6 @@
import type { HomeAssistant } from "../types";
import type { EntityRegistryEntry } from "./entity/entity_registry";
import { entityRegistryByEntityId } from "./entity/entity_registry";
export const voiceAssistants = {
conversation: { domain: "assist_pipeline", name: "Assist" },
@@ -52,3 +54,13 @@ export const listExposedEntities = (hass: HomeAssistant) =>
hass.callWS<{ exposed_entities: Record<string, ExposeEntitySettings> }>({
type: "homeassistant/expose_entity/list",
});
export const getEntityVoiceAssistantsIds = (
entityRegistry: EntityRegistryEntry[],
entityId: string
) => {
const entity = entityRegistryByEntityId(entityRegistry)[entityId];
return Object.keys(voiceAssistants).filter(
(vaKey) => entity?.options?.[vaKey]?.should_expose
);
};

View File

@@ -16,6 +16,7 @@ export type HumidifierEntity = HassEntityBase & {
mode?: string;
action?: HumidifierAction;
available_modes?: string[];
target_humidity_step?: number;
};
};

View File

@@ -52,6 +52,9 @@ export interface BaseActionConfig {
export interface ConfirmationRestrictionConfig {
text?: string;
title?: string;
confirm_text?: string;
dismiss_text?: string;
exemptions?: RestrictionConfig[];
}

View File

@@ -49,6 +49,7 @@ export interface LovelaceBaseViewConfig {
title?: string;
path?: string;
icon?: string;
show_icon_and_title?: boolean;
theme?: string;
panel?: boolean;
background?: string | LovelaceViewBackgroundConfig;

View File

@@ -221,6 +221,7 @@ export interface DurationSelector {
duration: {
enable_day?: boolean;
enable_millisecond?: boolean;
allow_negative?: boolean;
} | null;
}
@@ -376,7 +377,7 @@ interface SelectBoxOptionImage {
}
export interface SelectOption {
value: any;
value: string;
label: string;
description?: string;
image?: string | SelectBoxOptionImage;

View File

@@ -44,14 +44,27 @@ export const updateUsesProgress = (entity: UpdateEntity): boolean =>
supportsFeature(entity, UpdateEntityFeature.PROGRESS) &&
entity.attributes.update_percentage !== null;
export const updateAvailable = (
entity: UpdateEntity,
showSkipped = false
): boolean =>
entity.state === BINARY_STATE_ON ||
(showSkipped && Boolean(entity.attributes.skipped_version));
export const updateCanInstall = (
entity: UpdateEntity,
showSkipped = false
): boolean =>
(entity.state === BINARY_STATE_ON ||
(showSkipped && Boolean(entity.attributes.skipped_version))) &&
updateAvailable(entity, showSkipped) &&
supportsFeature(entity, UpdateEntityFeature.INSTALL);
export const updateCanNotInstall = (
entity: UpdateEntity,
showSkipped = false
): boolean =>
updateAvailable(entity, showSkipped) &&
!supportsFeature(entity, UpdateEntityFeature.INSTALL);
export const latestVersionIsSkipped = (entity: UpdateEntity): boolean =>
!!(
entity.attributes.latest_version &&
@@ -108,13 +121,17 @@ export const filterUpdateEntities = (
);
});
export const filterUpdateEntitiesWithInstall = (
export const filterUpdateEntitiesParameterized = (
entities: HassEntities,
showSkipped = false
showSkipped = false,
showNotInstallable = false
) =>
filterUpdateEntities(entities).filter((entity) =>
updateCanInstall(entity, showSkipped)
);
filterUpdateEntities(entities).filter((entity) => {
if (showNotInstallable) {
return updateCanNotInstall(entity, showSkipped);
}
return updateCanInstall(entity, showSkipped);
});
export const checkForEntityUpdates = async (
element: HTMLElement,

View File

@@ -766,7 +766,10 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
}
.content-wrapper.settings-view .fade-bottom {
bottom: var(--ha-space-18);
bottom: calc(
var(--ha-space-14) +
max(var(--safe-area-inset-bottom), var(--ha-space-4))
);
}
.child-view {

View File

@@ -1,4 +1,3 @@
import { mdiAppleKeyboardCommand } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
@@ -154,6 +153,10 @@ const _SHORTCUTS: Section[] = [
shortcut: ["M"],
descriptionTranslationKey: "ui.dialogs.shortcuts.other.my_link",
},
{
shortcut: ["Shift", "/"],
descriptionTranslationKey: "ui.dialogs.shortcuts.other.show_shortcuts",
},
],
},
];
@@ -184,9 +187,7 @@ class DialogShortcuts extends LitElement {
html`<span
>${shortcutKey === CTRL_CMD
? isMac
? html`<ha-svg-icon
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
? "⌘"
: this.hass.localize("ui.panel.config.automation.editor.ctrl")
: typeof shortcutKey === "string"
? shortcutKey

View File

@@ -152,7 +152,7 @@ export const provideHass = (
for (const ent of ensureArray(newEntities)) {
hass().entities[ent.entityId] = {
entity_id: ent.entityId,
name: ent.name,
name: ent.attributes.friendly_name || null,
icon: ent.icon,
platform: "demo",
labels: [],

View File

@@ -50,7 +50,7 @@ export const ScrollableFadeMixin = <T extends Constructor<LitElement>>(
/**
* Safe area padding in pixels for the scrollable element.
*/
protected scrollFadeSafeAreaPadding = 16;
protected scrollFadeSafeAreaPadding = 4;
/**
* Scroll threshold in pixels for showing the fades.
@@ -73,6 +73,9 @@ export const ScrollableFadeMixin = <T extends Constructor<LitElement>>(
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated?.(changedProperties);
if (this.scrollableElement) {
this._updateScrollableState(this.scrollableElement);
}
this._attachScrollableElement();
}
@@ -83,6 +86,8 @@ export const ScrollableFadeMixin = <T extends Constructor<LitElement>>(
disconnectedCallback() {
this._detachScrollableElement();
this._contentScrolled = false;
this._contentScrollable = false;
super.disconnectedCallback();
}
@@ -125,16 +130,16 @@ export const ScrollableFadeMixin = <T extends Constructor<LitElement>>(
position: absolute;
left: 0;
right: 0;
height: var(--ha-space-4);
height: var(--ha-space-2);
pointer-events: none;
transition: opacity 180ms ease-in-out;
background: linear-gradient(
to bottom,
var(--shadow-color),
transparent
);
border-radius: var(--ha-border-radius-square);
opacity: 0;
background: linear-gradient(
to bottom,
var(--ha-color-shadow-scrollable-fade),
transparent
);
}
.fade-top {
top: 0;

View File

@@ -58,6 +58,7 @@ import { fullEntitiesContext } from "../../../../data/context";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type {
Action,
DeviceAction,
NonConditionAction,
RepeatAction,
ServiceAction,
@@ -233,6 +234,13 @@ export default class HaAutomationActionRow extends LitElement {
private _renderRow() {
const type = getAutomationActionType(this.action);
const target =
type === "service" && "target" in this.action
? (this.action as ServiceAction).target
: type === "device_id" && (this.action as DeviceAction).device_id
? { device_id: (this.action as DeviceAction).device_id }
: undefined;
return html`
${type === "service" && "action" in this.action && this.action.action
? html`
@@ -254,9 +262,7 @@ export default class HaAutomationActionRow extends LitElement {
${capitalizeFirstLetter(
describeAction(this.hass, this._entityReg, this.action)
)}
${type === "service" && "target" in this.action
? this._renderTargets((this.action as ServiceAction).target)
: nothing}
${target ? this._renderTargets(target) : nothing}
</h3>
<slot name="icons" slot="icons"></slot>

View File

@@ -2062,6 +2062,7 @@ class DialogAddAutomationElement
.content.column {
flex-direction: column;
gap: var(--ha-space-3);
}
ha-md-list {

View File

@@ -1504,14 +1504,7 @@ export default class HaAutomationAddFromTarget extends LitElement {
box-shadow: inset var(--ha-shadow-offset-x-lg)
calc(var(--ha-shadow-offset-y-lg) * -1) var(--ha-shadow-blur-lg)
var(--ha-shadow-spread-lg) var(--ha-color-shadow-light);
}
@media (prefers-color-scheme: dark) {
.targets-show-more {
box-shadow: inset var(--ha-shadow-offset-x-lg)
calc(var(--ha-shadow-offset-y-lg) * -1) var(--ha-shadow-blur-lg)
var(--ha-shadow-spread-lg) var(--ha-color-shadow-dark);
}
z-index: 2;
}
@media all and (max-width: 870px), all and (max-height: 500px) {

View File

@@ -285,6 +285,8 @@ export class HaAutomationAddItems extends LitElement {
border-radius: var(--ha-border-radius-md);
background: var(--ha-color-fill-neutral-normal-resting);
padding: 0 var(--ha-space-2) 0 var(--ha-space-1);
border: var(--ha-border-width-sm) solid
var(--ha-color-border-neutral-quiet);
color: var(--ha-color-on-neutral-normal);
overflow: hidden;
}

View File

@@ -157,7 +157,7 @@ class DialogAutomationSave extends LitElement implements HassDialog {
`
: nothing}
${this._visibleOptionals.includes("description")
? html` <ha-textarea
? html`<ha-textarea
.label=${this.hass.localize(
"ui.panel.config.automation.editor.description.label"
)}
@@ -168,6 +168,7 @@ class DialogAutomationSave extends LitElement implements HassDialog {
autogrow
.value=${this._newDescription}
.helper=${supportsMarkdownHelper(this.hass.localize)}
helperPersistent
@input=${this._valueChanged}
></ha-textarea>`
: nothing}
@@ -570,7 +571,7 @@ ${dump(this._params.config)}
ha-category-picker,
ha-labels-picker,
ha-area-picker,
ha-chip-set {
ha-chip-set:has(> ha-assist-chip) {
margin-top: 16px;
}
ha-alert {

View File

@@ -76,6 +76,7 @@ import "./types/ha-automation-condition-template";
import "./types/ha-automation-condition-time";
import "./types/ha-automation-condition-trigger";
import "./types/ha-automation-condition-zone";
import type { DeviceCondition } from "../../../../data/device/device_automation";
export interface ConditionElement extends LitElement {
condition: Condition;
@@ -184,6 +185,14 @@ export default class HaAutomationConditionRow extends LitElement {
}
private _renderRow() {
const target =
"target" in (this.conditionDescriptions[this.condition.condition] || {})
? (this.condition as PlatformCondition).target
: "device_id" in this.condition &&
(this.condition as DeviceCondition).device_id
? { device_id: [(this.condition as DeviceCondition).device_id] }
: undefined;
return html`
<ha-condition-icon
slot="leading-icon"
@@ -194,10 +203,7 @@ export default class HaAutomationConditionRow extends LitElement {
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
)}
${"target" in
(this.conditionDescriptions[this.condition.condition] || {})
? this._renderTargets((this.condition as PlatformCondition).target)
: nothing}
${target ? this._renderTargets(target) : nothing}
</h3>
<slot name="icons" slot="icons"></slot>

View File

@@ -57,6 +57,7 @@ import "../../../components/ha-filter-devices";
import "../../../components/ha-filter-entities";
import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-labels";
import "../../../components/ha-filter-voice-assistants";
import "../../../components/ha-icon-button";
import "../../../components/ha-md-divider";
import "../../../components/ha-md-menu";
@@ -115,6 +116,8 @@ import { showCategoryRegistryDetailDialog } from "../category/show-dialog-catego
import { configSections } from "../ha-panel-config";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import { showNewAutomationDialog } from "./show-dialog-new-automation";
import { getEntityVoiceAssistantsIds } from "../../../data/expose";
import "../voice-assistants/expose/expose-assistant-icon";
type AutomationItem = AutomationEntity & {
name: string;
@@ -376,6 +379,31 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
></ha-icon-button>
`,
},
voice_assistants: {
title: localize(
"ui.panel.config.voice_assistants.expose.headers.assistants"
),
type: "flex",
defaultHidden: true,
minWidth: "160px",
maxWidth: "160px",
template: (automation) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entityReg,
automation.entity_id
);
return html` ${exposedToVoiceAssistantIds.length !== 0
? exposedToVoiceAssistantIds.map(
(vaId) =>
html` <voice-assistants-expose-assistant-icon
.assistant=${vaId}
.hass=${this.hass}
>
</voice-assistants-expose-assistant-icon>`
)
: "—"}`;
},
},
};
return columns;
}
@@ -633,6 +661,15 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-categories>
<ha-filter-voice-assistants
.hass=${this.hass}
.value=${this._filters["ha-filter-voice-assistants"]?.value}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-voice-assistants"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-voice-assistants>
<ha-filter-blueprints
.hass=${this.hass}
.type=${"automation"}
@@ -1003,8 +1040,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(categoryItems)
: new Set([...items].filter((x) => categoryItems!.has(x)));
}
if (
} else if (
key === "ha-filter-labels" &&
Array.isArray(filter.value) &&
filter.value.length
@@ -1026,6 +1062,29 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(labelItems)
: new Set([...items].filter((x) => labelItems!.has(x)));
} else if (
key === "ha-filter-voice-assistants" &&
Array.isArray(filter.value) &&
filter.value.length
) {
const assistItems = new Set<string>();
this.automations
.filter((automation) =>
getEntityVoiceAssistantsIds(
this._entityReg,
automation.entity_id
).some((va) => (filter.value as string[]).includes(va))
)
.forEach((automation) => assistItems.add(automation.entity_id));
if (!items) {
items = assistItems;
continue;
}
items =
"intersection" in items
? // @ts-ignore
items.intersection(assistItems)
: new Set([...items].filter((x) => assistItems!.has(x)));
}
}
this._filteredAutomations = items ? [...items] : undefined;

View File

@@ -223,6 +223,8 @@ export class HaAutomationRowTargets extends LitElement {
background: var(--ha-color-fill-neutral-normal-resting);
padding: 0 var(--ha-space-2) 0 var(--ha-space-1);
color: var(--ha-color-on-neutral-normal);
border: var(--ha-border-width-sm) solid
var(--ha-color-border-neutral-quiet);
overflow: hidden;
height: 32px;
}

View File

@@ -56,6 +56,7 @@ import { isTrigger, subscribeTrigger } from "../../../../data/automation";
import { describeTrigger } from "../../../../data/automation_i18n";
import { validateConfig } from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import type { DeviceTrigger } from "../../../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { TriggerDescriptions } from "../../../../data/trigger";
import { isTriggerList } from "../../../../data/trigger";
@@ -196,6 +197,15 @@ export default class HaAutomationTriggerRow extends LitElement {
const yamlMode = this._yamlMode || !supported;
const target =
type === "platform" &&
"target" in
this.triggerDescriptions[(this.trigger as PlatformTrigger).trigger]
? (this.trigger as PlatformTrigger).target
: type === "device" && (this.trigger as DeviceTrigger).device_id
? { device_id: (this.trigger as DeviceTrigger).device_id }
: undefined;
return html`
${type === "list"
? html`<ha-svg-icon
@@ -210,11 +220,7 @@ export default class HaAutomationTriggerRow extends LitElement {
></ha-trigger-icon>`}
<h3 slot="header">
${describeTrigger(this.trigger, this.hass, this._entityReg)}
${type === "platform" &&
"target" in
this.triggerDescriptions[(this.trigger as PlatformTrigger).trigger]
? this._renderTargets((this.trigger as PlatformTrigger).target)
: nothing}
${target ? this._renderTargets(target) : nothing}
</h3>
<slot name="icons" slot="icons"></slot>

View File

@@ -206,8 +206,8 @@ class HaBlueprintOverview extends LitElement {
sortable: true,
valueColumn: "usageCount",
type: "numeric",
minWidth: "100px",
maxWidth: "120px",
minWidth: "90px",
maxWidth: "90px",
template: (blueprint) => {
const count = blueprint.usageCount ?? 0;
return html`

View File

@@ -20,7 +20,6 @@ import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import { showCategoryRegistryDetailDialog } from "./show-dialog-category-registry-detail";
const ADD_NEW_ID = "___ADD_NEW___";
const NO_CATEGORIES_ID = "___NO_CATEGORIES___";
@customElement("ha-category-picker")
export class HaCategoryPicker extends SubscribeMixin(LitElement) {
@@ -101,17 +100,11 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
);
private _getCategories = memoizeOne(
(categories: CategoryRegistryEntry[] | undefined): PickerComboBoxItem[] => {
if (!categories || categories.length === 0) {
return [
{
id: NO_CATEGORIES_ID,
primary: this.hass.localize(
"ui.components.category-picker.no_categories"
),
icon_path: mdiTag,
},
];
(
categories: CategoryRegistryEntry[] | undefined
): PickerComboBoxItem[] | undefined => {
if (!categories) {
return undefined;
}
const items = categories.map<PickerComboBoxItem>((category) => ({
@@ -210,10 +203,6 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
const value = ev.detail.value;
if (value === NO_CATEGORIES_ID) {
return;
}
if (!value) {
this._setValue(undefined);
return;

View File

@@ -1,19 +1,16 @@
import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item";
import { mdiDotsVertical, mdiRefresh } from "@mdi/js";
import {
mdiDotsVertical,
mdiLocationEnter,
mdiLocationExit,
mdiRefresh,
} from "@mdi/js";
import type { HassEntities } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
import "../../../components/ha-alert";
import "../../../components/ha-bar";
import "../../../components/ha-button-menu";
import "../../../components/ha-card";
import "../../../components/ha-check-list-item";
import "../../../components/ha-list-item";
import "../../../components/ha-metric";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type {
HassioSupervisorInfo,
@@ -26,13 +23,16 @@ import {
} from "../../../data/hassio/supervisor";
import {
checkForEntityUpdates,
filterUpdateEntitiesWithInstall,
filterUpdateEntitiesParameterized,
} from "../../../data/update";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import "../dashboard/ha-config-updates";
import { showJoinBetaDialog } from "./updates/show-dialog-join-beta";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "@home-assistant/webawesome/dist/components/divider/divider";
@customElement("ha-config-section-updates")
class HaConfigSectionUpdates extends LitElement {
@@ -53,7 +53,11 @@ class HaConfigSectionUpdates extends LitElement {
}
protected render(): TemplateResult {
const canInstallUpdates = this._filterUpdateEntitiesWithInstall(
const canInstallUpdates = this._filterInstallableUpdateEntities(
this.hass.states,
this._showSkipped
);
const notInstallableUpdates = this._filterNotInstallableUpdateEntities(
this.hass.states,
this._showSkipped
);
@@ -73,57 +77,86 @@ class HaConfigSectionUpdates extends LitElement {
.path=${mdiRefresh}
@click=${this._checkUpdates}
></ha-icon-button>
<ha-button-menu multi>
<ha-dropdown @wa-select=${this._handleOverflowAction}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-check-list-item
left
@request-selected=${this._toggleSkipped}
.selected=${this._showSkipped}
<ha-dropdown-item
type="checkbox"
.checked=${this._showSkipped}
value="show_skipped"
>
${this.hass.localize("ui.panel.config.updates.show_skipped")}
</ha-check-list-item>
</ha-dropdown-item>
${this._supervisorInfo
? html`
<li divider role="separator"></li>
<ha-list-item
@request-selected=${this._toggleBeta}
<wa-divider></wa-divider>
<ha-dropdown-item
value="toggle_beta"
.disabled=${this._supervisorInfo.channel === "dev"}
>
<ha-svg-icon
.path=${this._supervisorInfo.channel === "stable"
? mdiLocationEnter
: mdiLocationExit}
slot="icon"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.updates.${this._supervisorInfo.channel === "stable" ? "join" : "leave"}_beta`
)}
${this._supervisorInfo.channel === "stable"
? this.hass.localize("ui.panel.config.updates.join_beta")
: this.hass.localize(
"ui.panel.config.updates.leave_beta"
)}
</ha-list-item>
</ha-dropdown-item>
`
: ""}
</ha-button-menu>
: nothing}
</ha-dropdown>
</div>
<div class="content">
<ha-card outlined>
<div class="card-content">
${canInstallUpdates.length
? html`
${canInstallUpdates.length
? html`
<ha-card outlined>
<div class="card-content">
<ha-config-updates
.hass=${this.hass}
.narrow=${this.narrow}
.updateEntities=${canInstallUpdates}
.isInstallable=${true}
showAll
></ha-config-updates>
`
: html`
<div class="no-updates">
${this.hass.localize(
"ui.panel.config.updates.no_updates"
)}
</div>
`}
</div>
</ha-card>
</div>
</ha-card>
`
: nothing}
${notInstallableUpdates.length
? html`
<ha-card outlined>
<div class="card-content">
<ha-config-updates
.hass=${this.hass}
.narrow=${this.narrow}
.updateEntities=${notInstallableUpdates}
.isInstallable=${false}
showAll
></ha-config-updates>
</div>
</ha-card>
`
: nothing}
${canInstallUpdates.length + notInstallableUpdates.length
? nothing
: html`
<ha-card outlined>
<div class="no-updates">
${this.hass.localize("ui.panel.config.updates.no_updates")}
</div>
</ha-card>
`}
</div>
</hass-subpage>
`;
@@ -133,27 +166,19 @@ class HaConfigSectionUpdates extends LitElement {
this._supervisorInfo = await fetchHassioSupervisorInfo(this.hass);
}
private _toggleSkipped(ev: CustomEvent<RequestSelectedDetail>): void {
if (ev.detail.source !== "property") {
return;
}
this._showSkipped = !this._showSkipped;
}
private async _toggleBeta(
ev: CustomEvent<RequestSelectedDetail>
): Promise<void> {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
if (this._supervisorInfo!.channel === "stable") {
showJoinBetaDialog(this, {
join: async () => this._setChannel("beta"),
});
} else {
this._setChannel("stable");
private _handleOverflowAction(
ev: CustomEvent<{ item: { value: string } }>
): void {
if (ev.detail.item.value === "toggle_beta") {
if (this._supervisorInfo!.channel === "stable") {
showJoinBetaDialog(this, {
join: () => this._setChannel("beta"),
});
} else {
this._setChannel("stable");
}
} else if (ev.detail.item.value === "show_skipped") {
this._showSkipped = !this._showSkipped;
}
}
@@ -177,9 +202,14 @@ class HaConfigSectionUpdates extends LitElement {
checkForEntityUpdates(this, this.hass);
}
private _filterUpdateEntitiesWithInstall = memoizeOne(
private _filterInstallableUpdateEntities = memoizeOne(
(entities: HassEntities, showSkipped: boolean) =>
filterUpdateEntitiesWithInstall(entities, showSkipped)
filterUpdateEntitiesParameterized(entities, showSkipped, false)
);
private _filterNotInstallableUpdateEntities = memoizeOne(
(entities: HassEntities, showSkipped: boolean) =>
filterUpdateEntitiesParameterized(entities, showSkipped, true)
);
static styles = css`

View File

@@ -31,7 +31,7 @@ import {
import type { UpdateEntity } from "../../../data/update";
import {
checkForEntityUpdates,
filterUpdateEntitiesWithInstall,
filterUpdateEntitiesParameterized,
} from "../../../data/update";
import {
QuickBarMode,
@@ -161,24 +161,27 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
total: 0,
};
private _pages = memoizeOne((cloudStatus, isCloudLoaded) => [
isCloudLoaded
? [
{
component: "cloud",
path: "/config/cloud",
name: "Home Assistant Cloud",
info: cloudStatus,
iconPath: mdiCloudLock,
iconColor: "#3B808E",
translationKey: "cloud",
},
...configSections.dashboard,
]
: configSections.dashboard,
configSections.dashboard_2,
configSections.dashboard_3,
]);
private _pages = memoizeOne(
(cloudStatus, isCloudLoaded, hasExternalSettings) => [
isCloudLoaded
? [
{
component: "cloud",
path: "/config/cloud",
name: "Home Assistant Cloud",
info: cloudStatus,
iconPath: mdiCloudLock,
iconColor: "#3B808E",
translationKey: "cloud",
},
...configSections.dashboard,
]
: configSections.dashboard,
hasExternalSettings ? configSections.dashboard_external_settings : [],
configSections.dashboard_2,
configSections.dashboard_3,
]
);
public hassSubscribe(): UnsubscribeFunc[] {
return [
@@ -203,7 +206,7 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
protected render(): TemplateResult {
const { updates: canInstallUpdates, total: totalUpdates } =
this._filterUpdateEntitiesWithInstall(
this._filterUpdateEntitiesParameterized(
this.hass.states,
this.hass.entities
);
@@ -288,6 +291,7 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
.total=${totalUpdates}
.updateEntities=${canInstallUpdates}
.isInstallable=${true}
></ha-config-updates>
${totalUpdates > canInstallUpdates.length
? html`
@@ -310,7 +314,8 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
: ""}
${this._pages(
this.cloudStatus,
isComponentLoaded(this.hass, "cloud")
isComponentLoaded(this.hass, "cloud"),
this.hass.auth.external?.config.hasSettingsScreen
).map((categoryPages) =>
categoryPages.length === 0
? nothing
@@ -344,14 +349,16 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
showShortcutsDialog(this);
}
private _filterUpdateEntitiesWithInstall = memoizeOne(
private _filterUpdateEntitiesParameterized = memoizeOne(
(
entities: HomeAssistant["states"],
entityRegistry: HomeAssistant["entities"]
): { updates: UpdateEntity[]; total: number } => {
const updates = filterUpdateEntitiesWithInstall(entities).filter(
(entity) => !entityRegistry[entity.entity_id]?.hidden
);
const updates = filterUpdateEntitiesParameterized(
entities,
false,
false
).filter((entity) => !entityRegistry[entity.entity_id]?.hidden);
return {
updates: updates.slice(0, updates.length === 3 ? updates.length : 2),

View File

@@ -32,6 +32,8 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
@property({ type: Number }) public total?: number;
@property({ attribute: false }) public isInstallable = true;
@state() private _devices?: DeviceRegistryEntry[];
@state() private _entities?: EntityRegistryEntry[];
@@ -89,9 +91,16 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
return html`
<div class="title" role="heading" aria-level="2">
${this.hass.localize("ui.panel.config.updates.title", {
count: this.total || this.updateEntities.length,
})}
${this.isInstallable
? this.hass.localize("ui.panel.config.updates.title", {
count: this.total || this.updateEntities.length,
})
: this.hass.localize(
"ui.panel.config.updates.title_not_installable",
{
count: this.total || this.updateEntities.length,
}
)}
</div>
<ha-md-list>
${updates.map((entity) => {

View File

@@ -64,6 +64,7 @@ import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-integrations";
import "../../../components/ha-filter-labels";
import "../../../components/ha-filter-states";
import "../../../components/ha-filter-voice-assistants";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-md-divider";
@@ -115,6 +116,8 @@ import { isHelperDomain } from "../helpers/const";
import "../integrations/ha-integration-overflow-menu";
import { showAddIntegrationDialog } from "../integrations/show-add-integration-dialog";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import { getEntityVoiceAssistantsIds } from "../../../data/expose";
import "../voice-assistants/expose/expose-assistant-icon";
export interface StateEntity extends Omit<
EntityRegistryEntry,
@@ -493,6 +496,31 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
template: (entry) =>
entry.label_entries.map((lbl) => lbl.name).join(" "),
},
voice_assistants: {
title: localize(
"ui.panel.config.voice_assistants.expose.headers.assistants"
),
type: "flex",
defaultHidden: true,
minWidth: "160px",
maxWidth: "160px",
template: (entry) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entities,
entry.entity_id
);
return html` ${exposedToVoiceAssistantIds.length !== 0
? exposedToVoiceAssistantIds.map(
(vaId) =>
html` <voice-assistants-expose-assistant-icon
.assistant=${vaId}
.hass=${this.hass}
>
</voice-assistants-expose-assistant-icon>`
)
: "—"}`;
},
},
})
);
@@ -637,6 +665,16 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
filteredEntities = filteredEntities.filter((entity) =>
entity.labels.some((lbl) => (filter as string[]).includes(lbl))
);
} else if (
key === "ha-filter-voice-assistants" &&
Array.isArray(filter) &&
filter.length
) {
filteredEntities = filteredEntities.filter((entity) =>
getEntityVoiceAssistantsIds(this._entities, entity.entity_id).some(
(va) => (filter as string[]).includes(va)
)
);
}
});
@@ -1076,6 +1114,15 @@ ${
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-labels>
<ha-filter-voice-assistants
.hass=${this.hass}
.value=${this._filters["ha-filter-voice-assistants"]}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-voice-assistants"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-voice-assistants>
${
includeAddDeviceFab
? html`<ha-fab
@@ -1128,6 +1175,7 @@ ${
const subEntry = this._searchParms.get("sub_entry");
const device = this._searchParms.get("device");
const label = this._searchParms.get("label");
const voiceAssistant = this._searchParms.get("voice_assistant");
if (!domain && !configEntry && !label && !device) {
return;
@@ -1140,6 +1188,7 @@ ${
"ha-filter-integrations": domain ? [domain] : [],
"ha-filter-devices": device ? [device] : [],
"ha-filter-labels": label ? [label] : [],
"ha-filter-voice-assistants": voiceAssistant ? [voiceAssistant] : [],
config_entry: configEntry ? [configEntry] : [],
sub_entry: subEntry ? [subEntry] : [],
};

View File

@@ -105,7 +105,24 @@ export const configSections: Record<string, PageNavigation[]> = {
iconColor: "#3263C3",
},
],
dashboard_external_settings: [
{
path: "#external-app-configuration",
translationKey: "companion",
iconPath: mdiCellphoneCog,
iconColor: "#8E24AA",
},
],
dashboard_2: [
{
path: "/config/matter",
name: "Matter",
iconPath:
"M7.228375 6.41685c0.98855 0.80195 2.16365 1.3412 3.416275 1.56765V1.30093l1.3612 -0.7854275 1.360125 0.7854275V7.9845c1.252875 -0.226675 2.4283 -0.765875 3.41735 -1.56765l2.471225 1.4293c-4.019075 3.976275 -10.490025 3.976275 -14.5091 0l2.482925 -1.4293Zm3.00335 17.067575c1.43325 -5.47035 -1.8052 -11.074775 -7.2604 -12.564675v2.859675c1.189125 0.455 2.244125 1.202875 3.0672 2.174275L0.25 19.2955v1.5719l1.3611925 0.781175L7.39865 18.3068c0.430175 1.19825 0.550625 2.48575 0.35015 3.743l2.482925 1.434625ZM21.034 10.91975c-5.452225 1.4932 -8.6871 7.09635 -7.254025 12.564675l2.47655 -1.43035c-0.200025 -1.257275 -0.079575 -2.544675 0.35015 -3.743025l5.7832 3.337525L23.75 20.86315V19.2955L17.961475 15.9537c0.8233 -0.97115 1.878225 -1.718975 3.0672 -2.174275l0.005325 -2.859675Z",
iconColor: "#2458B3",
component: "matter",
translationKey: "matter",
},
{
path: "/config/zha",
name: "Zigbee",
@@ -173,12 +190,6 @@ export const configSections: Record<string, PageNavigation[]> = {
iconColor: "#5A87FA",
component: ["person", "users"],
},
{
path: "#external-app-configuration",
translationKey: "companion",
iconPath: mdiCellphoneCog,
iconColor: "#8E24AA",
},
{
path: "/config/system",
translationKey: "system",

View File

@@ -260,8 +260,6 @@ export class DialogHelperDetail extends LitElement {
open
@closed=${this.closeDialog}
class=${classMap({ "button-left": !this._domain })}
scrimClickAction
escapeKeyAction
.hideActions=${!this._domain}
.heading=${createCloseHeading(
this.hass,

View File

@@ -105,7 +105,7 @@ class HaTimerForm extends LitElement {
<ha-checkbox
.configValue=${"restore"}
.checked=${this._restore}
@click=${this._toggleRestore}
@change=${this._toggleRestore}
.disabled=${this.disabled}
>
</ha-checkbox>
@@ -135,11 +135,8 @@ class HaTimerForm extends LitElement {
});
}
private _toggleRestore() {
if (this.disabled) {
return;
}
this._restore = !this._restore;
private _toggleRestore(ev) {
this._restore = ev.target.checked;
fireEvent(this, "value-changed", {
value: { ...this._item, restore: this._restore },
});

View File

@@ -51,6 +51,7 @@ import "../../../components/ha-filter-devices";
import "../../../components/ha-filter-entities";
import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-labels";
import "../../../components/ha-filter-voice-assistants";
import "../../../components/ha-icon";
import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-md-divider";
@@ -122,6 +123,8 @@ import "../integrations/ha-integration-overflow-menu";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import { isHelperDomain, type HelperDomain } from "./const";
import { showHelperDetailDialog } from "./show-dialog-helper-detail";
import { getEntityVoiceAssistantsIds } from "../../../data/expose";
import "../voice-assistants/expose/expose-assistant-icon";
interface HelperItem {
id: string;
@@ -205,7 +208,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
})
private _activeHiddenColumns?: string[];
@state() private _stateItems: HassEntity[] = [];
@state() private _helperEntities: HassEntity[] = [];
@state() private _disabledEntityEntries?: EntityRegistryEntry[];
@@ -223,6 +226,8 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
@state() private _diagnosticHandlers?: Record<string, boolean>;
@state() private _searchParms = new URLSearchParams(window.location.search);
@storage({
storage: "sessionStorage",
key: "helpers-table-filters",
@@ -245,7 +250,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg!: EntityRegistryEntry[];
@state() private _filteredStateItems?: string[] | null;
@state() private _filteredHelperEntityIds?: string[] | null;
private _sizeController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect.width,
@@ -480,6 +485,32 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
</ha-icon-overflow-menu>
`,
},
voice_assistants: {
title: localize(
"ui.panel.config.voice_assistants.expose.headers.assistants"
),
type: "flex",
defaultHidden: true,
minWidth: "160px",
maxWidth: "160px",
template: (helper) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entityReg,
helper.entity_id
);
return html` ${exposedToVoiceAssistantIds.length !== 0
? exposedToVoiceAssistantIds.map(
(vaId) => html`
<voice-assistants-expose-assistant-icon
.assistant=${vaId}
.hass=${this.hass}
>
</voice-assistants-expose-assistant-icon>
`
)
: "—"}`;
},
},
})
);
@@ -610,7 +641,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
protected render(): TemplateResult {
if (
!this.hass ||
this._stateItems === undefined ||
this._helperEntities === undefined ||
this._entityEntries === undefined ||
this._configEntries === undefined
) {
@@ -685,14 +716,14 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
(!this._sizeController.value && this.hass.dockedSidebar === "docked");
const helpers = this._getItems(
this.hass.localize,
this._stateItems,
this._helperEntities,
this._disabledEntityEntries || [],
this._entityEntries,
this._configEntries,
this._entityReg,
this._categories,
this._labels,
this._filteredStateItems
this._filteredHelperEntityIds
);
return html`
<hass-tabs-subpage-data-table
@@ -779,6 +810,15 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-categories>
<ha-filter-voice-assistants
.hass=${this.hass}
.value=${this._filters["ha-filter-voice-assistants"]}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-voice-assistants"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-voice-assistants>
${!this.narrow
? html`<ha-md-button-menu slot="selection-bar">
@@ -941,7 +981,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
filter.length
) {
const labelItems = new Set<string>();
this._stateItems
this._helperEntities
.filter((stateItem) =>
entityRegistryByEntityId(this._entityReg)[
stateItem.entity_id
@@ -960,14 +1000,13 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(labelItems)
: new Set([...items].filter((x) => labelItems!.has(x)));
}
if (
} else if (
key === "ha-filter-categories" &&
Array.isArray(filter) &&
filter.length
) {
const categoryItems = new Set<string>();
this._stateItems
this._helperEntities
.filter(
(stateItem) =>
filter[0] ===
@@ -987,10 +1026,85 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(categoryItems)
: new Set([...items].filter((x) => categoryItems!.has(x)));
} else if (
key === "ha-filter-voice-assistants" &&
Array.isArray(filter) &&
filter.length
) {
const assistItems = new Set<string>();
this._helperEntities
.filter((stateItem) =>
getEntityVoiceAssistantsIds(
this._entityReg,
stateItem.entity_id
).some((va) => (filter as string[]).includes(va))
)
.forEach((stateItem) => assistItems.add(stateItem.entity_id));
(this._disabledEntityEntries || [])
.filter((entry) =>
getEntityVoiceAssistantsIds(this._entityReg, entry.entity_id).some(
(va) => (filter as string[]).includes(va)
)
)
.forEach((entry) => assistItems.add(entry.entity_id));
if (!items) {
items = assistItems;
continue;
}
items =
"intersection" in items
? // @ts-ignore
items.intersection(assistItems)
: new Set([...items].filter((x) => assistItems!.has(x)));
}
}
this._filteredHelperEntityIds = items ? [...items] : undefined;
}
this._filteredStateItems = items ? [...items] : undefined;
public connectedCallback() {
super.connectedCallback();
window.addEventListener("location-changed", this._locationChanged);
window.addEventListener("popstate", this._popState);
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("location-changed", this._locationChanged);
window.removeEventListener("popstate", this._popState);
}
private _locationChanged = () => {
if (window.location.search.substring(1) !== this._searchParms.toString()) {
this._searchParms = new URLSearchParams(window.location.search);
this._setFiltersFromUrl();
}
};
private _popState = () => {
if (window.location.search.substring(1) !== this._searchParms.toString()) {
this._searchParms = new URLSearchParams(window.location.search);
this._setFiltersFromUrl();
}
};
private _setFiltersFromUrl() {
const device = this._searchParms.get("device");
const label = this._searchParms.get("label");
const category = this._searchParms.get("category");
const voiceAssistant = this._searchParms.get("voice_assistant");
if (!category && !label && !device) {
return;
}
this._filter = history.state?.filter || "";
this._filters = {
"ha-filter-devices": device ? [device] : [],
"ha-filter-labels": label ? [label] : [],
"ha-filter-categories": category ? [category] : [],
"ha-filter-voice-assistants": voiceAssistant ? [voiceAssistant] : [],
};
}
private _clearFilter() {
@@ -1093,7 +1207,7 @@ ${rejected
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._setFiltersFromUrl();
this._fetchEntitySources();
if (isComponentLoaded(this.hass, "diagnostics")) {
@@ -1206,6 +1320,10 @@ ${rejected
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._setFiltersFromUrl();
}
if (!this._entityEntries || !this._configEntries) {
return;
}
@@ -1225,7 +1343,7 @@ ${rejected
}
let changed =
!this._stateItems ||
!this._helperEntities ||
changedProps.has("_entityEntries") ||
changedProps.has("_configEntries") ||
changedProps.has("_entitySource");
@@ -1240,17 +1358,17 @@ ${rejected
const entityIds = Object.keys(this._entitySource);
const newStates = Object.values(this.hass!.states).filter(
const newHelpers = Object.values(this.hass!.states).filter(
(entity) =>
entityIds.includes(entity.entity_id) ||
isHelperDomain(computeStateDomain(entity))
);
if (
this._stateItems.length !== newStates.length ||
!this._stateItems.every((val, idx) => newStates[idx] === val)
this._helperEntities.length !== newHelpers.length ||
!this._helperEntities.every((val, idx) => newHelpers[idx] === val)
) {
this._stateItems = newStates;
this._helperEntities = newHelpers;
}
}

View File

@@ -327,7 +327,6 @@ class AddIntegrationDialog extends LitElement {
return html`<ha-dialog
open
@closed=${this.closeDialog}
scrimClickAction
hideActions
.heading=${createCloseHeading(
this.hass,

View File

@@ -23,7 +23,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { isDevVersion } from "../../../common/config/version";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { copyToClipboard } from "../../../common/util/copy-clipboard";
@@ -213,10 +212,7 @@ class HaConfigEntryRow extends LitElement {
? html`<ha-button slot="end" @click=${this._handleEnable}>
${this.hass.localize("ui.common.enable")}
</ha-button>`
: configPanel &&
(item.domain !== "matter" ||
isDevVersion(this.hass.config.version)) &&
!stateText
: configPanel && !stateText
? html`<a
slot="end"
href=${`/${configPanel}?config_entry=${item.entry_id}`}

View File

@@ -1,11 +1,19 @@
import { mdiAlertCircle, mdiCheckCircle, mdiPlus } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
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-card";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-expansion-panel";
import "../../../../../components/ha-fab";
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,
@@ -18,7 +26,6 @@ import {
import { showPromptDialog } from "../../../../../dialogs/generic/show-dialog-box";
import "../../../../../layouts/hass-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
@customElement("matter-config-dashboard")
export class MatterConfigDashboard extends LitElement {
@@ -26,6 +33,8 @@ export class MatterConfigDashboard extends LitElement {
@property({ type: Boolean }) public narrow = false;
@state() private _configEntry?: ConfigEntry;
@state() private _error?: string;
private _unsub?: UnsubscribeFunc;
@@ -35,10 +44,33 @@ export class MatterConfigDashboard extends LitElement {
this._stopRedirect();
}
protected render(): TemplateResult {
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
if (this.hass) {
this._fetchConfigEntry();
}
}
private _matterDeviceCount = memoizeOne(
(devices: HomeAssistant["devices"]): number =>
Object.values(devices).filter((device) =>
device.identifiers.some((identifier) => identifier[0] === "matter")
).length
);
protected render(): TemplateResult | typeof nothing {
if (!this._configEntry) {
return nothing;
}
const isOnline = this._configEntry.state === "loaded";
return html`
<hass-subpage .narrow=${this.narrow} .hass=${this.hass} header="Matter">
${isComponentLoaded(this.hass, "otbr")
<hass-subpage
.narrow=${this.narrow}
.hass=${this.hass}
header="Matter"
has-fab
>
${isComponentLoaded(this.hass, "thread")
? html`
<ha-button
appearance="plain"
@@ -51,53 +83,114 @@ export class MatterConfigDashboard extends LitElement {
)}</ha-button
>
`
: ""}
<div class="content">
<ha-card header="Matter">
<ha-alert alert-type="warning"
>${this.hass.localize(
"ui.panel.config.matter.panel.experimental_note"
)}</ha-alert
>
: nothing}
<div class="container">
<ha-card class="network-status">
<div class="card-content">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
${this.hass.localize("ui.panel.config.matter.panel.add_devices")}
<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">
${canCommissionMatterExternal(this.hass)
? html`<ha-button
appearance="plain"
@click=${this._startMobileCommissioning}
>${this.hass.localize(
"ui.panel.config.matter.panel.mobile_app_commisioning"
)}</ha-button
>`
: ""}
<ha-button appearance="plain" @click=${this._commission}
>${this.hass.localize(
"ui.panel.config.matter.panel.commission_device"
)}</ha-button
<ha-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
appearance="plain"
size="small"
>
<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
${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>
</div>
<a href="/config/matter/add" slot="fab">
<ha-fab
.label=${this.hass.localize(
"ui.panel.config.matter.panel.add_device"
)}
extended
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
</a>
</hass-subpage>
`;
}
@@ -236,27 +329,101 @@ export class MatterConfigDashboard extends LitElement {
}
}
static styles = [
haStyle,
css`
ha-alert[alert-type="warning"] {
position: relative;
top: -16px;
}
.content {
padding: 24px 0 32px;
max-width: 600px;
margin: 0 auto;
direction: ltr;
}
ha-card:first-child {
margin-bottom: 16px;
}
a[slot="toolbar-icon"] {
text-decoration: none;
}
`,
];
private async _fetchConfigEntry(): Promise<void> {
const configEntries = await getConfigEntries(this.hass, {
domain: "matter",
});
if (configEntries.length) {
this._configEntry = configEntries[0];
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
ha-card {
margin: auto;
margin-top: var(--ha-space-4);
max-width: 500px;
}
ha-card .card-actions {
display: flex;
justify-content: flex-end;
}
.network-status div.heading {
display: flex;
align-items: center;
}
.network-status div.heading .icon {
margin-inline-end: var(--ha-space-4);
}
.network-status div.heading ha-svg-icon {
--mdc-icon-size: 48px;
}
.network-status div.heading .details {
font-size: var(--ha-font-size-xl);
}
.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));
}
.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: 0 0 var(--ha-space-4);
}
.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;
}
a[slot="fab"] {
text-decoration: none;
}
`,
];
}
}
declare global {

View File

@@ -45,9 +45,9 @@ class DialogThreadDataset extends LitElement implements HassDialog {
<div>
Network name: ${dataset.network_name}<br />
Channel: ${dataset.channel}<br />
Dataset id: ${dataset.dataset_id}<br />
Pan id: ${dataset.pan_id}<br />
Extended Pan id: ${dataset.extended_pan_id}<br />
Dataset ID: ${dataset.dataset_id}<br />
PAN ID: ${dataset.pan_id}<br />
Extended PAN ID: ${dataset.extended_pan_id}<br />
${hasOTBR
? html`OTBR URL: ${otbrInfo.url}<br />

View File

@@ -372,7 +372,7 @@ export class HaConfigLogs extends LitElement {
@media all and (max-width: 870px) {
ha-generic-picker {
max-width: 50%;
max-width: max(30%, 160px);
}
ha-button {
max-width: 100%;

View File

@@ -51,6 +51,7 @@ import "../../../components/ha-filter-devices";
import "../../../components/ha-filter-entities";
import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-labels";
import "../../../components/ha-filter-voice-assistants";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-md-divider";
@@ -107,6 +108,8 @@ import { showAssignCategoryDialog } from "../category/show-dialog-assign-categor
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
import { configSections } from "../ha-panel-config";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import { getEntityVoiceAssistantsIds } from "../../../data/expose";
import "../voice-assistants/expose/expose-assistant-icon";
type SceneItem = SceneEntity & {
name: string;
@@ -410,6 +413,31 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
</ha-icon-overflow-menu>
`,
},
voice_assistants: {
title: localize(
"ui.panel.config.voice_assistants.expose.headers.assistants"
),
type: "flex",
defaultHidden: true,
minWidth: "160px",
maxWidth: "160px",
template: (scene) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entityReg,
scene.entity_id
);
return html` ${exposedToVoiceAssistantIds.length !== 0
? exposedToVoiceAssistantIds.map(
(vaId) =>
html` <voice-assistants-expose-assistant-icon
.assistant=${vaId}
.hass=${this.hass}
>
</voice-assistants-expose-assistant-icon>`
)
: "—"}`;
},
},
};
return columns;
@@ -652,6 +680,15 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-categories>
<ha-filter-voice-assistants
.hass=${this.hass}
.value=${this._filters["ha-filter-voice-assistants"]?.value}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-voice-assistants"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-voice-assistants>
${!this.narrow
? html`<ha-md-button-menu slot="selection-bar">
@@ -887,8 +924,7 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(categoryItems)
: new Set([...items].filter((x) => categoryItems!.has(x)));
}
if (
} else if (
key === "ha-filter-labels" &&
Array.isArray(filter.value) &&
filter.value.length
@@ -910,6 +946,28 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(labelItems)
: new Set([...items].filter((x) => labelItems!.has(x)));
} else if (
key === "ha-filter-voice-assistants" &&
Array.isArray(filter.value) &&
filter.value.length
) {
const assistItems = new Set<string>();
this.scenes
.filter((scene) =>
getEntityVoiceAssistantsIds(this._entityReg, scene.entity_id).some(
(va) => (filter.value as string[]).includes(va)
)
)
.forEach((scene) => assistItems.add(scene.entity_id));
if (!items) {
items = assistItems;
continue;
}
items =
"intersection" in items
? // @ts-ignore
items.intersection(assistItems)
: new Set([...items].filter((x) => assistItems!.has(x)));
}
}
this._filteredScenes = items ? [...items] : undefined;

View File

@@ -53,6 +53,7 @@ import "../../../components/ha-filter-devices";
import "../../../components/ha-filter-entities";
import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-labels";
import "../../../components/ha-filter-voice-assistants";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-md-divider";
@@ -111,6 +112,8 @@ import { showAssignCategoryDialog } from "../category/show-dialog-assign-categor
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
import { configSections } from "../ha-panel-config";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import { getEntityVoiceAssistantsIds } from "../../../data/expose";
import "../voice-assistants/expose/expose-assistant-icon";
type ScriptItem = ScriptEntity & {
name: string;
@@ -398,8 +401,32 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
</ha-icon-overflow-menu>
`,
},
voice_assistants: {
title: localize(
"ui.panel.config.voice_assistants.expose.headers.assistants"
),
type: "flex",
defaultHidden: true,
minWidth: "160px",
maxWidth: "160px",
template: (script) => {
const exposedToVoiceAssistantIds = getEntityVoiceAssistantsIds(
this._entityReg,
script.entity_id
);
return html` ${exposedToVoiceAssistantIds.length !== 0
? exposedToVoiceAssistantIds.map(
(vaId) =>
html` <voice-assistants-expose-assistant-icon
.assistant=${vaId}
.hass=${this.hass}
>
</voice-assistants-expose-assistant-icon>`
)
: "—"}`;
},
},
};
return columns;
}
);
@@ -635,6 +662,15 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-categories>
<ha-filter-voice-assistants
.hass=${this.hass}
.value=${this._filters["ha-filter-voice-assistants"]?.value}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-voice-assistants"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-voice-assistants>
<ha-filter-blueprints
.hass=${this.hass}
.type=${"script"}
@@ -893,8 +929,7 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(categoryItems)
: new Set([...items].filter((x) => categoryItems!.has(x)));
}
if (
} else if (
key === "ha-filter-labels" &&
Array.isArray(filter.value) &&
filter.value.length
@@ -916,6 +951,28 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
? // @ts-ignore
items.intersection(labelItems)
: new Set([...items].filter((x) => labelItems!.has(x)));
} else if (
key === "ha-filter-voice-assistants" &&
Array.isArray(filter.value) &&
filter.value.length
) {
const assistItems = new Set<string>();
this.scripts
.filter((script) =>
getEntityVoiceAssistantsIds(this._entityReg, script.entity_id).some(
(va) => (filter.value as string[]).includes(va)
)
)
.forEach((script) => assistItems.add(script.entity_id));
if (!items) {
items = assistItems;
continue;
}
items =
"intersection" in items
? // @ts-ignore
items.intersection(assistItems)
: new Set([...items].filter((x) => assistItems!.has(x)));
}
}
this._filteredScripts = items ? [...items] : undefined;

View File

@@ -23,7 +23,6 @@ export class VoiceAssistantExposeAssistantIcon extends LitElement {
render() {
if (!this.assistant || !voiceAssistants[this.assistant]) return nothing;
return html`
<div class="container" id="container">
<img

View File

@@ -21,6 +21,8 @@ class EventSubscribeCard extends LitElement {
@state() private _subscribed?: () => void;
@state() private _eventFilter = "";
@state() private _events: {
id: number;
event: HassEvent;
@@ -30,6 +32,8 @@ class EventSubscribeCard extends LitElement {
private _eventCount = 0;
@state() _ignoredEventsCount = 0;
public disconnectedCallback() {
super.disconnectedCallback();
if (this._subscribed) {
@@ -70,6 +74,16 @@ class EventSubscribeCard extends LitElement {
.value=${this._eventType}
@input=${this._valueChanged}
></ha-textfield>
<ha-textfield
.label=${this.hass!.localize(
"ui.panel.developer-tools.tabs.events.filter_events"
)}
.value=${this._eventFilter}
.disabled=${this._subscribed !== undefined}
helperPersistent
.helper=${`${this.hass!.localize("ui.panel.developer-tools.tabs.events.filter_helper")}${this._ignoredEventsCount ? ` ${this.hass!.localize("ui.panel.developer-tools.tabs.events.filter_ignored", { count: this._ignoredEventsCount })}` : ""}`}
@input=${this._filterChanged}
></ha-textfield>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
@@ -135,6 +149,46 @@ class EventSubscribeCard extends LitElement {
this._error = undefined;
}
private _filterChanged(ev): void {
this._eventFilter = ev.target.value;
}
private _testEventFilter(event: HassEvent): boolean {
if (!this._eventFilter) {
return true;
}
const searchStr = this._eventFilter;
function visit(node) {
// Handle primitives directly
if (node === null || typeof node !== "object") {
return String(node).includes(searchStr);
}
// Handle arrays and plain objects
for (const key in node) {
if (!Object.prototype.hasOwnProperty.call(node, key)) continue;
// Check key
if (key.includes(searchStr)) return true;
const value = node[key];
// Check primitive value
if (value === null || typeof value !== "object") {
if (String(value).includes(searchStr)) return true;
} else if (visit(value)) {
// Recurse into nested object/array
return true;
}
}
return false;
}
return visit(event);
}
private async _startOrStopListening(): Promise<void> {
if (this._subscribed) {
this._subscribed();
@@ -144,6 +198,10 @@ class EventSubscribeCard extends LitElement {
try {
this._subscribed =
await this.hass!.connection.subscribeEvents<HassEvent>((event) => {
if (!this._testEventFilter(event)) {
this._ignoredEventsCount++;
return;
}
const tail =
this._events.length > 30
? this._events.slice(0, 29)
@@ -168,6 +226,7 @@ class EventSubscribeCard extends LitElement {
private _clearEvents(): void {
this._events = [];
this._eventCount = 0;
this._ignoredEventsCount = 0;
this._error = undefined;
}

View File

@@ -201,6 +201,7 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
label: this.hass.localize(
"ui.panel.developer-tools.tabs.statistics.fix_issue.fix"
),
type: "icon",
template: (statistic) =>
html`${statistic.issues
? html`<ha-button

View File

@@ -283,13 +283,18 @@ class PanelEnergy extends LitElement {
["grid", "solar", "battery"].includes(source.type)
);
const hasPower =
this._prefs.energy_sources.some(
(source) =>
(source.type === "solar" && source.stat_rate) ||
(source.type === "battery" && source.stat_rate) ||
(source.type === "grid" && source.power?.length)
) || this._prefs.device_consumption.some((device) => device.stat_rate);
const hasPowerSource = this._prefs.energy_sources.some(
(source) =>
(source.type === "solar" && source.stat_rate) ||
(source.type === "battery" && source.stat_rate) ||
(source.type === "grid" && source.power?.length)
);
const hasDevicePower = this._prefs.device_consumption.some(
(device) => device.stat_rate
);
const hasPower = hasPowerSource || hasDevicePower;
const hasWater =
this._prefs.energy_sources.some((source) => source.type === "water") ||
@@ -314,7 +319,10 @@ class PanelEnergy extends LitElement {
if (hasPower) {
views.push(POWER_VIEW);
}
if (views.length > 1) {
if (
hasPowerSource ||
[hasEnergy, hasGas, hasWater].filter(Boolean).length > 1
) {
views.unshift(OVERVIEW_VIEW);
}
return {

View File

@@ -31,6 +31,7 @@ import { formatTime } from "../../../../../common/datetime/format_time";
import type { ECOption } from "../../../../../resources/echarts/echarts";
import { filterXSS } from "../../../../../common/util/xss";
import type { StatisticPeriod } from "../../../../../data/recorder";
import { getSuggestedPeriod } from "../../../../../data/energy";
export function getSuggestedMax(period: StatisticPeriod, end: Date): number {
let suggestedMax = new Date(end);
@@ -56,10 +57,6 @@ export function getSuggestedMax(period: StatisticPeriod, end: Date): number {
return suggestedMax.getTime();
}
export function getSuggestedPeriod(dayDifference: number): StatisticPeriod {
return dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour";
}
function createYAxisLabelFormatter(locale: FrontendLocaleData) {
let previousValue: number | undefined;
@@ -95,7 +92,7 @@ export function getCommonOptions(
type: "time",
min: start,
max: getSuggestedMax(
detailedDailyData ? "5minute" : getSuggestedPeriod(dayDifference),
getSuggestedPeriod(start, end, detailedDailyData),
end
),
},

View File

@@ -1,4 +1,4 @@
import { differenceInDays, endOfToday, isToday, startOfToday } from "date-fns";
import { endOfToday, isToday, startOfToday } from "date-fns";
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -18,6 +18,7 @@ import type {
import {
getEnergyDataCollection,
getEnergySolarForecasts,
getSuggestedPeriod,
} from "../../../../data/energy";
import type { Statistics, StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
@@ -354,7 +355,7 @@ export class HuiEnergySolarGraphCard
) {
const data: LineSeriesOption[] = [];
const dayDifference = differenceInDays(end || new Date(), start);
const period = getSuggestedPeriod(start, end);
// Process solar forecast data.
solarSources.forEach((source) => {
@@ -370,10 +371,10 @@ export class HuiEnergySolarGraphCard
if (dateObj < start || (end && dateObj > end)) {
return;
}
if (dayDifference > 35) {
if (period === "month") {
dateObj.setDate(1);
}
if (dayDifference > 2) {
if (period === "month" || period === "day") {
dateObj.setHours(0, 0, 0, 0);
} else {
dateObj.setMinutes(0, 0, 0);

View File

@@ -67,6 +67,19 @@ export const SUM_DEVICE_CLASSES = [
"water",
];
// Additional sources for sensor device classes from entity attributes
// Maps device_class -> array of { domain, attribute } to include in aggregation
export const SENSOR_ATTRIBUTE_SOURCES: Record<
string,
{ domain: string; attribute: string }[]
> = {
temperature: [{ domain: "climate", attribute: "current_temperature" }],
humidity: [
{ domain: "climate", attribute: "current_humidity" },
{ domain: "humidifier", attribute: "current_humidity" },
],
};
export interface AreaCardFeatureContext extends LovelaceCardFeatureContext {
exclude_entities?: string[];
}
@@ -251,6 +264,24 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
}
);
private _domainEntityIds = memoizeOne(
(
entities: HomeAssistant["entities"],
areaId: string,
domains: string[],
excludeEntities?: string[]
): string[] => {
const filter = generateEntityFilter(this.hass, {
area: areaId,
entity_category: "none",
domain: domains,
});
return Object.keys(entities).filter(
(id) => filter(id) && !excludeEntities?.includes(id)
);
}
);
private _computeActiveAlertStates(): HassEntity[] {
const areaId = this._config?.area;
const area = areaId ? this.hass.areas[areaId] : undefined;
@@ -359,58 +390,91 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
: this.hass.formatEntityState(stateObj);
}
const entityIds = groupedEntities.get(sensorClass);
const sensorEntityIds = groupedEntities.get(sensorClass) || [];
const values: number[] = [];
let uom: string | undefined;
if (!entityIds) {
return undefined;
// Track devices that have sensor entities contributing values
// to avoid duplicate readings from climate/humidifier attributes
const devicesWithSensorValues = new Set<string>();
for (const entityId of sensorEntityIds) {
const stateObj = this.hass.states[entityId];
if (
stateObj &&
!isUnavailableState(stateObj.state) &&
isNumericState(stateObj) &&
!isNaN(Number(stateObj.state))
) {
if (!uom) {
uom = stateObj.attributes.unit_of_measurement;
}
if (stateObj.attributes.unit_of_measurement === uom) {
values.push(Number(stateObj.state));
// Track the device this sensor belongs to
const entityEntry = this.hass.entities[entityId];
if (entityEntry?.device_id) {
devicesWithSensorValues.add(entityEntry.device_id);
}
}
}
}
// Ensure all entities have state
const entities = entityIds
.map((entityId) => this.hass.states[entityId])
.filter(Boolean);
// Collect values from additional attribute sources
const attrSources = SENSOR_ATTRIBUTE_SOURCES[sensorClass];
if (attrSources) {
const domains = [...new Set(attrSources.map((s) => s.domain))];
const attrEntityIds = this._domainEntityIds(
this.hass.entities,
area.area_id,
domains,
excludeEntities
);
if (entities.length === 0) {
return undefined;
for (const entityId of attrEntityIds) {
const stateObj = this.hass.states[entityId];
if (!stateObj) continue;
// Skip if this entity's device already has a sensor contributing values
const entityEntry = this.hass.entities[entityId];
if (
entityEntry?.device_id &&
devicesWithSensorValues.has(entityEntry.device_id)
) {
continue;
}
const domain = entityId.split(".")[0];
const source = attrSources.find((s) => s.domain === domain);
if (!source) continue;
const attrValue = stateObj.attributes[source.attribute];
if (attrValue == null || isNaN(Number(attrValue))) continue;
if (!uom) {
// Determine unit from attribute
uom = this._getAttributeUnit(sensorClass, domain);
}
values.push(Number(attrValue));
}
}
// If only one entity, return its formatted state
if (entities.length === 1) {
const stateObj = entities[0];
return isUnavailableState(stateObj.state)
? ""
: this.hass.formatEntityState(stateObj);
}
// Use the first entity's unit_of_measurement for formatting
const uom = entities.find(
(entity) => entity.attributes.unit_of_measurement
)?.attributes.unit_of_measurement;
// Ensure all entities have the same unit_of_measurement
const validEntities = entities.filter(
(entity) =>
entity.attributes.unit_of_measurement === uom &&
isNumericState(entity) &&
!isNaN(Number(entity.state))
);
if (validEntities.length === 0) {
if (values.length === 0) {
return undefined;
}
const value = SUM_DEVICE_CLASSES.includes(sensorClass)
? this._computeSumState(validEntities)
: this._computeMedianState(validEntities);
? values.reduce((acc, v) => acc + v, 0)
: this._computeMedianValue(values);
const formattedAverage = formatNumber(value, this.hass!.locale, {
const formattedValue = formatNumber(value, this.hass.locale, {
maximumFractionDigits: 1,
});
const formattedUnit = uom
? `${blankBeforeUnit(uom, this.hass!.locale)}${uom}`
? `${blankBeforeUnit(uom, this.hass.locale)}${uom}`
: "";
return `${formattedAverage}${formattedUnit}`;
return `${formattedValue}${formattedUnit}`;
})
.filter(Boolean)
.join(" · ");
@@ -418,20 +482,25 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
return sensorStates;
}
private _computeSumState(entities: HassEntity[]): number {
return entities.reduce((acc, entity) => acc + Number(entity.state), 0);
private _getAttributeUnit(sensorClass: string, domain: string): string {
// Return the expected unit for attributes from specific domains
if (sensorClass === "temperature" && domain === "climate") {
return this.hass.config.unit_system.temperature;
}
if (sensorClass === "humidity") {
return "%";
}
return "";
}
private _computeMedianState(entities: HassEntity[]): number {
const sortedStates = entities
.map((entity) => Number(entity.state))
.sort((a, b) => a - b);
if (sortedStates.length % 2 === 0) {
const medianIndex = sortedStates.length / 2;
return (sortedStates[medianIndex] + sortedStates[medianIndex - 1]) / 2;
private _computeMedianValue(values: number[]): number {
const sortedValues = [...values].sort((a, b) => a - b);
if (sortedValues.length % 2 === 0) {
const medianIndex = sortedValues.length / 2;
return (sortedValues[medianIndex] + sortedValues[medianIndex - 1]) / 2;
}
const medianIndex = Math.floor(sortedStates.length / 2);
return sortedStates[medianIndex];
const medianIndex = Math.floor(sortedValues.length / 2);
return sortedValues[medianIndex];
}
private _featurePosition = memoizeOne((config: AreaCardConfig) => {

View File

@@ -94,10 +94,12 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
(changedProps.has("_config") && this._config?.entities)
) {
const computedStyles = getComputedStyle(this);
this._calendars = this._config!.entities.map((entity, idx) => ({
entity_id: entity,
backgroundColor: getColorByIndex(idx, computedStyles),
}));
if (this._config?.entities) {
this._calendars = this._config.entities.map((entity, idx) => ({
entity_id: entity,
backgroundColor: getColorByIndex(idx, computedStyles),
}));
}
}
}

View File

@@ -73,13 +73,18 @@ export class HuiCard extends ConditionalListenerMixin<LovelaceCardConfig>(
};
// If the element has fixed rows or columns, we use the values from the element
// unless the user has already configured their own
if (elementOptions.fixed_rows) {
mergedConfig.rows = elementOptions.rows;
if (configOptions.rows === undefined) {
mergedConfig.rows = elementOptions.rows;
}
delete mergedConfig.min_rows;
delete mergedConfig.max_rows;
}
if (elementOptions.fixed_columns) {
mergedConfig.columns = elementOptions.columns;
if (configOptions.columns === undefined) {
mergedConfig.columns = elementOptions.columns;
}
delete mergedConfig.min_columns;
delete mergedConfig.max_columns;
}

View File

@@ -1,63 +1,116 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import "../../../components/ha-card";
import "../../../components/ha-button";
import "../../../components/ha-icon";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCard } from "../types";
import { handleAction } from "../common/handle-action";
import type { LovelaceCard, LovelaceCardEditor } from "../types";
import type { EmptyStateCardConfig } from "./types";
@customElement("hui-empty-state-card")
export class HuiEmptyStateCard extends LitElement implements LovelaceCard {
public static async getConfigElement(): Promise<LovelaceCardEditor> {
await import("../editor/config-elements/hui-empty-state-card-editor");
return document.createElement("hui-empty-state-card-editor");
}
public static getStubConfig(): EmptyStateCardConfig {
return {
type: "empty-state",
title: "Welcome Home",
content: "This is an empty state card.",
};
}
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: EmptyStateCardConfig;
public getCardSize(): number {
return 2;
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
public setConfig(_config: EmptyStateCardConfig): void {}
public setConfig(config: EmptyStateCardConfig): void {
this._config = config;
}
protected render() {
if (!this.hass) {
if (!this.hass || !this._config) {
return nothing;
}
return html`
<ha-card
.header=${this.hass.localize(
"ui.panel.lovelace.cards.empty_state.title"
)}
class=${classMap({
"content-only": this._config.content_only ?? false,
})}
>
<div class="card-content">
${this.hass.localize(
"ui.panel.lovelace.cards.empty_state.no_devices"
)}
</div>
<div class="card-actions">
<ha-button appearance="plain" href="/config/integrations/dashboard">
${this.hass.localize(
"ui.panel.lovelace.cards.empty_state.go_to_integrations_page"
)}
</ha-button>
<div class="container">
${this._config.icon
? html`<ha-icon .icon=${this._config.icon}></ha-icon>`
: nothing}
${this._config.title ? html`<h1>${this._config.title}</h1>` : nothing}
${this._config.content
? html`<p>${this._config.content}</p>`
: nothing}
${this._config.tap_action && this._config.action_button_text
? html`
<ha-button @click=${this._handleAction}>
${this._config.action_button_text}
</ha-button>
`
: nothing}
</div>
</ha-card>
`;
}
private _handleAction(): void {
if (this._config?.tap_action && this.hass) {
handleAction(this, this.hass, this._config, "tap");
}
}
static styles = css`
.content {
margin-top: -1em;
padding: 16px;
:host {
display: block;
height: 100%;
}
.card-actions a {
text-decoration: none;
ha-card {
height: 100%;
}
ha-button {
margin-left: -8px;
margin-inline-start: -8px;
margin-inline-end: initial;
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
height: 100%;
padding: var(--ha-space-8) var(--ha-space-4);
box-sizing: border-box;
gap: var(--ha-space-4);
max-width: 640px;
margin: 0 auto;
}
ha-icon {
--mdc-icon-size: var(--ha-space-12);
color: var(--secondary-text-color);
}
h1 {
margin: 0;
font-size: var(--ha-font-size-xl);
font-weight: 500;
}
p {
margin: 0;
color: var(--secondary-text-color);
}
.content-only {
background: none;
box-shadow: none;
border: none;
}
`;
}

View File

@@ -11,7 +11,10 @@ import { findEntities } from "../common/find-entities";
import type { LovelaceElement, LovelaceElementConfig } from "../elements/types";
import type { LovelaceCard, LovelaceCardEditor } from "../types";
import { createStyledHuiElement } from "./picture-elements/create-styled-hui-element";
import type { PictureElementsCardConfig } from "./types";
import {
PREVIEW_CLICK_CALLBACK,
type PictureElementsCardConfig,
} from "./types";
import type { PersonEntity } from "../../../data/person";
@customElement("hui-picture-elements-card")
@@ -166,6 +169,7 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard {
.aspectRatio=${this._config.aspect_ratio}
.darkModeFilter=${this._config.dark_mode_filter}
.darkModeImage=${darkModeImage}
@click=${this._handleImageClick}
></hui-image>
${this._elements}
</div>
@@ -221,6 +225,19 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard {
curCardEl === elToReplace ? newCardEl : curCardEl
);
}
private _handleImageClick(ev: MouseEvent): void {
if (!this.preview || !this._config?.[PREVIEW_CLICK_CALLBACK]) {
return;
}
const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect();
const x = ((ev.clientX - rect.left) / rect.width) * 100;
const y = ((ev.clientY - rect.top) / rect.height) * 100;
// only the edited card has this callback
this._config[PREVIEW_CLICK_CALLBACK](x, y);
}
}
declare global {

View File

@@ -8,7 +8,10 @@ import { createSearchParam } from "../../../common/url/search-params";
import "../../../components/ha-card";
import "../../../components/ha-icon-next";
import "../../../components/ha-tooltip";
import { getEnergyDataCollection } from "../../../data/energy";
import {
getEnergyDataCollection,
getSuggestedPeriod,
} from "../../../data/energy";
import type {
Statistics,
StatisticsMetaData,
@@ -26,10 +29,7 @@ import { hasConfigOrEntitiesChanged } from "../common/has-changed";
import { processConfigEntities } from "../common/process-config-entities";
import type { EntityConfig } from "../entity-rows/types";
import type { LovelaceCard, LovelaceGridOptions } from "../types";
import {
getSuggestedMax,
getSuggestedPeriod,
} from "./energy/common/energy-chart-options";
import { getSuggestedMax } from "./energy/common/energy-chart-options";
import type { StatisticsGraphCardConfig } from "./types";
export const DEFAULT_DAYS_TO_SHOW = 30;
@@ -268,9 +268,7 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
return (
this._config?.period ??
(this._energyStart && this._energyEnd
? getSuggestedPeriod(
differenceInDays(this._energyEnd, this._energyStart)
)
? getSuggestedPeriod(this._energyStart, this._energyEnd)
: undefined)
);
}

View File

@@ -58,8 +58,12 @@ export interface ConditionalCardConfig extends LovelaceCardConfig {
}
export interface EmptyStateCardConfig extends LovelaceCardConfig {
content: string;
content_only?: boolean;
icon?: string;
title?: string;
content?: string;
action_button_text?: string;
tap_action?: ActionConfig;
}
export interface EntityCardConfig extends LovelaceCardConfig {
@@ -483,6 +487,10 @@ export interface PictureCardConfig extends LovelaceCardConfig {
alt_text?: string;
}
// Symbol for preview click callback - preserved through spreads, not serialized
// This allows the editor to attach a callback that only exists on the edited card's config
export const PREVIEW_CLICK_CALLBACK = Symbol("previewClickCallback");
export interface PictureElementsCardConfig extends LovelaceCardConfig {
title?: string;
image?: string | MediaSelectorValue;
@@ -497,6 +505,7 @@ export interface PictureElementsCardConfig extends LovelaceCardConfig {
theme?: string;
dark_mode_image?: string | MediaSelectorValue;
dark_mode_filter?: string;
[PREVIEW_CLICK_CALLBACK]?: (x: number, y: number) => void;
}
export interface PictureEntityCardConfig extends LovelaceCardConfig {

View File

@@ -21,5 +21,8 @@ export const confirmAction = async (
hass.localize("ui.panel.lovelace.cards.actions.action_confirmation", {
action,
}),
title: config.title,
dismissText: config.dismiss_text,
confirmText: config.confirm_text,
});
};

View File

@@ -368,6 +368,7 @@ export const generateViewConfig = (
path: string,
title: string | undefined,
icon: string | undefined,
show_icon_and_title: boolean | undefined,
entities: HassEntities
): LovelaceViewConfig => {
const ungroupedEntitites: Record<string, string[]> = {};
@@ -497,6 +498,9 @@ export const generateViewConfig = (
if (icon) {
view.icon = icon;
}
if (show_icon_and_title) {
view.show_icon_and_title = show_icon_and_title;
}
return view;
};
@@ -517,6 +521,7 @@ export const generateDefaultViewConfig = (
const path = "default_view";
const title = "Home";
const icon = undefined;
const show_icon_and_title = undefined;
// In the case of a default view, we want to use the group order attribute
const groupOrders = {};
@@ -566,6 +571,7 @@ export const generateDefaultViewConfig = (
path,
title,
icon,
show_icon_and_title,
splittedByGroups.ungrouped
);

View File

@@ -7,6 +7,11 @@ const calcPoints = (
height: number,
limits?: { minX?: number; maxX?: number; minY?: number; maxY?: number }
) => {
// handling empty history (for example unavailable for long time)
if (history.length === 0) {
return { points: [], yAxisOrigin: height };
}
let yAxisOrigin = height;
let minY = limits?.minY ?? history[0][1];
let maxY = limits?.maxY ?? history[0][1];

View File

@@ -89,6 +89,9 @@ export const handleAction = async (
) ||
actionConfig.action,
}),
title: actionConfig.confirmation.title,
dismissText: actionConfig.confirmation.dismiss_text,
confirmText: actionConfig.confirmation.confirm_text,
}))
) {
return;

View File

@@ -0,0 +1,153 @@
import { mdiGestureTap } from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { assert, assign, boolean, object, optional, string } from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type { EmptyStateCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import { actionConfigStruct } from "../structs/action-struct";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
content_only: optional(boolean()),
icon: optional(string()),
title: optional(string()),
content: optional(string()),
action_button_text: optional(string()),
tap_action: optional(actionConfigStruct),
})
);
@customElement("hui-empty-state-card-editor")
export class HuiEmptyStateCardEditor
extends LitElement
implements LovelaceCardEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: EmptyStateCardConfig;
public setConfig(config: EmptyStateCardConfig): void {
assert(config, cardConfigStruct);
this._config = config;
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
{
name: "style",
selector: {
select: {
mode: "box",
options: (
[
{ value: "card", image: "card" },
{ value: "content-only", image: "text_only" },
] as const
).map((style) => ({
label: localize(
`ui.panel.lovelace.editor.card.empty_state.style_options.${style.value}`
),
image: {
src: `/static/images/form/markdown_${style.image}.svg`,
src_dark: `/static/images/form/markdown_${style.image}_dark.svg`,
flip_rtl: true,
},
value: style.value,
})),
},
},
},
{ name: "icon", selector: { icon: {} } },
{ name: "title", selector: { text: {} } },
{ name: "content", selector: { text: { multiline: true } } },
{
name: "interactions",
type: "expandable",
flatten: true,
iconPath: mdiGestureTap,
schema: [
{ name: "action_button_text", selector: { text: {} } },
{
name: "tap_action",
selector: {
ui_action: {
default_action: "none",
},
},
},
],
},
] as const satisfies readonly HaFormSchema[]
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const data = {
...this._config,
style: this._config.content_only ? "content-only" : "card",
};
const schema = this._schema(this.hass.localize);
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent): void {
const config = { ...ev.detail.value };
if (config.style === "content-only") {
config.content_only = true;
} else {
delete config.content_only;
}
delete config.style;
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "style":
case "content":
case "action_button_text":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.empty_state.${schema.name}`
);
default:
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
}
};
}
declare global {
interface HTMLElementTagNameMap {
"hui-empty-state-card-editor": HuiEmptyStateCardEditor;
}
}

View File

@@ -15,12 +15,16 @@ import {
} from "superstruct";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import "../../../../components/ha-form/ha-form";
import "../../../../components/ha-icon";
import "../../../../components/ha-switch";
import type { HomeAssistant } from "../../../../types";
import type { PictureElementsCardConfig } from "../../cards/types";
import {
PREVIEW_CLICK_CALLBACK,
type PictureElementsCardConfig,
} from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import "../hui-sub-element-editor";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
@@ -28,7 +32,6 @@ import type { EditDetailElementEvent, SubElementEditorConfig } from "../types";
import { configElementStyle } from "./config-elements-style";
import "../hui-picture-elements-card-row-editor";
import type { LovelaceElementConfig } from "../../elements/types";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LocalizeFunc } from "../../../../common/translations/localize";
const genericElementConfigStruct = type({
@@ -66,6 +69,44 @@ export class HuiPictureElementsCardEditor
this._config = config;
}
private _onPreviewClick = (x: number, y: number): void => {
if (this._subElementEditorConfig?.type === "element") {
this._handlePositionClick(x, y);
}
};
private _handlePositionClick(x: number, y: number): void {
if (
!this._subElementEditorConfig?.elementConfig ||
this._subElementEditorConfig.type !== "element" ||
this._subElementEditorConfig.elementConfig.type === "conditional"
) {
return;
}
const elementConfig = this._subElementEditorConfig
.elementConfig as LovelaceElementConfig;
const currentPosition = (elementConfig.style as Record<string, string>)
?.position;
if (currentPosition && currentPosition !== "absolute") {
return;
}
const newElement = {
...elementConfig,
style: {
...((elementConfig.style as Record<string, string>) || {}),
left: `${Math.round(x)}%`,
top: `${Math.round(y)}%`,
},
};
const updateEvent = new CustomEvent("config-changed", {
detail: { config: newElement },
});
this._handleSubElementChanged(updateEvent);
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
@@ -138,6 +179,16 @@ export class HuiPictureElementsCardEditor
if (this._subElementEditorConfig) {
return html`
${this._subElementEditorConfig.type === "element" &&
this._subElementEditorConfig.elementConfig?.type !== "conditional"
? html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.lovelace.editor.card.picture-elements.position_hint"
)}
</ha-alert>
`
: nothing}
<hui-sub-element-editor
.hass=${this.hass}
.config=${this._subElementEditorConfig}
@@ -181,6 +232,7 @@ export class HuiPictureElementsCardEditor
return;
}
// no need to attach the preview click callback here, no element is being edited
fireEvent(this, "config-changed", { config: ev.detail.value });
}
@@ -191,7 +243,8 @@ export class HuiPictureElementsCardEditor
const config = {
...this._config,
elements: ev.detail.elements as LovelaceElementConfig[],
} as LovelaceCardConfig;
[PREVIEW_CLICK_CALLBACK]: this._onPreviewClick,
} as PictureElementsCardConfig;
fireEvent(this, "config-changed", { config });
@@ -232,7 +285,12 @@ export class HuiPictureElementsCardEditor
elementConfig: value,
};
fireEvent(this, "config-changed", { config: this._config });
fireEvent(this, "config-changed", {
config: {
...this._config,
[PREVIEW_CLICK_CALLBACK]: this._onPreviewClick,
},
});
}
private _editDetailElement(ev: HASSDomEvent<EditDetailElementEvent>): void {

View File

@@ -10,7 +10,9 @@ export const getElementStubConfig = async (
): Promise<LovelaceElementConfig> => {
let elementConfig: LovelaceElementConfig = { type };
if (type !== "conditional") {
if (type === "conditional") {
elementConfig = { type, conditions: [], elements: [] };
} else {
elementConfig.style = { left: "50%", top: "50%" };
}

View File

@@ -89,7 +89,11 @@ export abstract class HuiElementEditor<
}
public set value(config: T | undefined) {
if (this._config && deepEqual(config, this._config)) {
// Compare symbols to detect callback changes (e.g., preview click handlers)
if (
this._config &&
deepEqual(config, this._config, { compareSymbols: true })
) {
return;
}
this._config = config;

View File

@@ -73,6 +73,12 @@ export class HuiViewEditor extends LitElement {
icon: {},
},
},
{
name: "show_icon_and_title",
selector: {
boolean: {},
},
},
{ name: "path", selector: { text: {} } },
{ name: "theme", selector: { theme: {} } },
{
@@ -207,6 +213,7 @@ export class HuiViewEditor extends LitElement {
case "path":
return this.hass!.localize("ui.panel.lovelace.editor.card.generic.url");
case "type":
case "show_icon_and_title":
case "subview":
case "max_columns":
case "dense_section_placement":
@@ -227,6 +234,7 @@ export class HuiViewEditor extends LitElement {
) => {
switch (schema.name) {
case "path":
case "show_icon_and_title":
case "subview":
case "dense_section_placement":
case "top_margin":

View File

@@ -112,7 +112,20 @@ class HuiSelectEntityRow extends LitElement implements LovelaceRow {
forwardHaptic(this, "light");
setSelectOption(this.hass!, stateObj.entity_id, option);
setSelectOption(this.hass!, stateObj.entity_id, option)
.catch((_err) => {
// silently swallow exception
})
.finally(() =>
setTimeout(() => {
const newStateObj = this.hass!.states[this._config!.entity];
if (newStateObj === stateObj) {
const select = this.shadowRoot?.querySelector("ha-select");
const index = select?.options.indexOf(stateObj.state) ?? -1;
select?.select(index);
}
}, 2000)
);
}
}

View File

@@ -496,6 +496,10 @@ class HUIRoot extends LitElement {
const tabs = html`<ha-tab-group @wa-tab-show=${this._handleViewSelected}>
${views.map((view, index) => {
const icon_and_title =
view.show_icon_and_title && view.icon && view.title;
const icon_only = view.icon && !icon_and_title;
const title_only = !icon_only && !icon_and_title;
const hidden =
!this._editMode && (view.subview || _isTabHiddenForUser(view));
return html`
@@ -506,7 +510,8 @@ class HUIRoot extends LitElement {
.disabled=${hidden}
aria-label=${ifDefined(view.title)}
class=${classMap({
icon: Boolean(view.icon),
"icon-only": Boolean(icon_only),
"icon-and-title": Boolean(icon_and_title),
"hide-tab": Boolean(hidden),
})}
>
@@ -523,18 +528,20 @@ class HUIRoot extends LitElement {
></ha-icon-button-arrow-prev>
`
: nothing}
${view.icon
? html`
<ha-icon
class=${classMap({
"child-view-icon": Boolean(view.subview),
})}
title=${ifDefined(view.title)}
.icon=${view.icon}
></ha-icon>
`
: view.title ||
this.hass.localize("ui.panel.lovelace.views.unnamed_view")}
${icon_only || icon_and_title
? html`<ha-icon
class=${classMap({
"child-view-icon": Boolean(view.subview),
})}
title=${ifDefined(view.title)}
.icon=${view.icon}
></ha-icon>`
: nothing}
${icon_and_title ? view.title : nothing}
${title_only
? view.title ||
this.hass.localize("ui.panel.lovelace.views.unnamed_view")
: nothing}
${this._editMode
? html`
<ha-icon-button
@@ -1489,24 +1496,27 @@ class HUIRoot extends LitElement {
ha-tab-group-tab {
--ha-tab-group-tab-height: var(--header-height, 56px);
}
.tab-bar ha-tab-group-tab {
--ha-tab-group-tab-height: var(--tab-bar-height, 56px);
}
ha-tab-group-tab[aria-selected="true"] .edit-icon {
display: inline-flex;
}
ha-tab-group-tab::part(base) {
padding-inline-start: var(--ha-tab-padding-start, var(--wa-space-l));
padding-inline-end: var(--ha-tab-padding-end, var(--wa-space-l));
}
ha-tab-group-tab::part(base) {
padding-top: calc((var(--ha-tab-group-tab-height) - 20px) / 2);
}
ha-tab-group-tab.icon::part(base) {
ha-tab-group-tab.icon-only::part(base),
ha-tab-group-tab.icon-and-title::part(base) {
padding-top: calc((var(--ha-tab-group-tab-height) - 20px) / 2 - 2px);
padding-bottom: calc(
(var(--ha-tab-group-tab-height) - 20px) / 2 - 4px
);
}
.tab-bar ha-tab-group-tab {
--ha-tab-group-tab-height: var(--tab-bar-height, 56px);
ha-tab-group-tab.icon-and-title ha-icon {
margin-inline-end: var(--ha-space-2);
}
.edit-mode ha-tab-group-tab[aria-selected="true"]::part(base) {
padding: 0;

View File

@@ -11,7 +11,10 @@ import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
import type { HeadingCardConfig } from "../../cards/types";
import type {
EmptyStateCardConfig,
HeadingCardConfig,
} from "../../cards/types";
import { computeAreaTileCardConfig } from "../areas/helpers/areas-strategy-helper";
import {
getSummaryLabel,
@@ -354,6 +357,26 @@ export class HomeAreaViewStrategy extends ReactiveElement {
});
}
// No sections, show empty state
if (sections.length === 0) {
return {
type: "panel",
cards: [
{
type: "empty-state",
icon: "mdi:sofa-outline",
content_only: true,
title: hass.localize(
"ui.panel.lovelace.strategy.areas.empty_state_title"
),
content: hass.localize(
"ui.panel.lovelace.strategy.areas.empty_state_content"
),
} as EmptyStateCardConfig,
],
};
}
// Allow between 2 and 3 columns (the max should be set to define the width of the header)
const maxColumns = clamp(sections.length, 2, 3);

View File

@@ -6,6 +6,7 @@ import type { AreasDisplayValue } from "../../../../components/ha-areas-display-
import { getEnergyPreferences } from "../../../../data/energy";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
import type { EmptyStateCardConfig } from "../../cards/types";
import { generateDefaultViewConfig } from "../../common/generate-lovelace-config";
export interface OriginalStatesViewStrategyConfig {
@@ -64,9 +65,33 @@ export class OriginalStatesViewStrategy extends ReactiveElement {
// User has no entities
if (view.cards!.length === 0) {
view.cards!.push({
type: "empty-state",
});
return {
type: "panel",
cards: [
{
type: "empty-state",
icon: "mdi:home-assistant",
content_only: true,
title: hass.localize(
"ui.panel.lovelace.strategy.original-states.empty_state_title"
),
content: hass.localize(
"ui.panel.lovelace.strategy.original-states.empty_state_content"
),
...(hass.user?.is_admin
? {
action_button_text: hass.localize(
"ui.panel.lovelace.strategy.original-states.empty_state_action"
),
tap_action: {
action: "navigate",
navigation_path: "/config/integrations/dashboard",
},
}
: {}),
} as EmptyStateCardConfig,
],
};
}
return view;

View File

@@ -1,7 +1,6 @@
import type { ActionDetail } from "@material/mwc-list";
import {
mdiAlphaABoxOutline,
mdiArrowLeft,
mdiDotsVertical,
mdiGrid,
mdiListBoxOutline,
@@ -97,7 +96,6 @@ class PanelMediaBrowser extends LitElement {
? html`
<ha-icon-button-arrow-prev
slot="navigationIcon"
.path=${mdiArrowLeft}
@click=${this._goBack}
></ha-icon-button-arrow-prev>
`

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