Compare commits

...

86 Commits

Author SHA1 Message Date
Wendelin
c74d59dfc6 Use feature flag 2025-11-25 16:56:53 +01:00
Wendelin
6c52b2252e Merge branch 'dev' of github.com:home-assistant/frontend into add-automation-element-by-target 2025-11-25 16:30:09 +01:00
Wendelin
e7edb1ba80 Review simplify render 2025-11-25 15:26:26 +01:00
Wendelin
161ccc752c Review 2025-11-25 15:20:38 +01:00
Jan-Philipp Benecke
fe50c1212a Add undo/redo functionality to dashboard editor (#27259)
* Add undo/redo functionality to dashboard editor

* Use controller and move toast to undo stack

* Store location and navigate to view

* Await and catch errors

* Process code review
2025-11-25 16:09:13 +02:00
Petar Petrov
c01fbf5f47 Revert "Use entity name for activity/logbook renderer" (#28098) 2025-11-25 13:24:51 +01:00
Wendelin
1a6d1580a2 Make show more, more visible 2025-11-25 12:41:42 +01:00
Wendelin
5fde08bc47 Fix scroll to 2025-11-25 12:35:33 +01:00
Wendelin
bf36d4477e fix services name 2025-11-25 12:19:01 +01:00
Aidan Timson
5c8da28b61 Standardise scrollable area fade (#28074)
* Extract fade CSS into shared file

* Switch to linear gradient instead of box shadow

* Refactor

* Refactor

* Scrollbar

* Fix

* Create mixin

* Move styles into mixin

* Dont replace mixin styles

* Reuse bar-box-shadow

* Move position
2025-11-25 13:04:08 +02:00
Rahul Harpal
bbb3c0208b Remove defaultHidden property from area configuration in automation, scene, and script pickers (#28096) 2025-11-25 10:55:12 +00:00
Wendelin
2a6e48bc23 fix styles 2025-11-25 11:54:39 +01:00
Wendelin
f9f9622873 Merge branch 'dev' of github.com:home-assistant/frontend into add-automation-element-by-target 2025-11-25 11:48:14 +01:00
Aidan Timson
82a3db39fe Use entity name for activity/logbook renderer (#28083) 2025-11-25 12:46:57 +02:00
Paul Bottein
254857b53f Improve sidebar and dashboard management logic (#28019)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-11-25 10:39:07 +00:00
Simon Lamon
1648be6b83 Rename DialogLovelaceDashboardConfigureStrategy (#28092) 2025-11-25 12:22:18 +02:00
Jan Layola
c4c774c217 Add total consumption to energy usage graph card (#28086)
* Add total consumption to energy usage graph card

* Add "used" suffix to energy usage graph card total

* Apply code review feedback

* Update src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-25 10:18:29 +00:00
Bram Kragten
6a0aab2088 Add dynamic condition support (#28058)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-25 11:17:53 +01:00
Wendelin
f89caaf1a5 Fix possible undefined entity 2025-11-25 11:03:56 +01:00
Wendelin
a3f0e3507b extract render items to own element 2025-11-25 10:57:51 +01:00
Wendelin
7ac34f424f Search into a external component 2025-11-25 08:59:27 +01:00
Aidan Timson
5a5593ec5b Add labels to device info card on device/service/hub page (#28082)
* Fix typo

* Add labels to device info card

* Lock

* Use token

* Add same class

* Apply suggestions from code review

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Match codebase style

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-25 08:47:35 +02:00
Paulus Schoutsen
2d39afdeac Render full chat log in voice debug (#27678) 2025-11-24 22:19:09 -05:00
Paul Bottein
974ac31277 Winter is coming (#28036)
* Winter is coming

* Set max to 30 on mobile

* Change storage key

* Use core

* Add browser level switch for winter mode

* Simplify logic
2025-11-24 22:11:38 +01:00
Petar Petrov
47e98d532d Fix energy distribution card not advancing to next day (#28081) 2025-11-24 20:44:22 +01:00
Jan Bouwhuis
2efc513221 Bump home-assistant-js-websocket to 9.6.0 (#28091) 2025-11-24 20:25:33 +01:00
Aidan Timson
5d820e3046 Fix color of dialog header and expansion panel (#28087) 2025-11-24 17:57:10 +01:00
Wendelin
c3cff3bcd3 Fix add to list item (#28073)
* Fix add to list item

* Remove unused css
2025-11-24 17:55:53 +02:00
Bram Kragten
e65a8a6b66 Add device database toggle to analytics (#27948)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-24 16:30:26 +01:00
Wendelin
e041658ca1 Add info icon 2025-11-24 15:13:34 +01:00
Aidan Timson
318452d6f6 Reset automation sidebar width on double click (#28076) 2025-11-24 14:37:11 +01:00
Wendelin
d410363b81 Group target results by domain 2025-11-24 13:03:51 +01:00
Wendelin
fa58b07801 use stringCompare instead of localecompare 2025-11-24 13:02:36 +01:00
Wendelin
0b89f7af12 Fix SingleHassServiceTarget 2025-11-24 11:08:26 +01:00
Wendelin
cb7f35979a Remove export from getAreasAndFloorsItems 2025-11-24 11:02:07 +01:00
Wendelin
1dbdad5605 Fix types 2025-11-24 10:54:54 +01:00
Wendelin
6890823c31 Make login button full width (#28072) 2025-11-24 10:49:13 +01:00
Wendelin
75a127d453 Merge branch 'dev' of github.com:home-assistant/frontend into add-automation-element-by-target 2025-11-24 10:45:12 +01:00
Simon Lamon
ba9bab38c9 Migrate updates dropdown to ha-dropdown (#28039)
* Migrate upgrade dropdown

* Apply suggestion from @MindFreeze

* Update src/panels/config/core/ha-config-section-updates.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-24 09:06:55 +00:00
Wendelin
085b3884af Fix add to selected action (#28062) 2025-11-24 11:01:58 +02:00
Simon Lamon
a9c816ed9c Migrate analytics dropdown to ha-dropdown (#28038)
* Migrate analytics dropdown

* Apply suggestion from @MindFreeze

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-24 08:52:22 +00:00
Wendelin
5c15354302 fix getAreasNestedInFloors 2025-11-21 13:43:59 +01:00
Wendelin
fedcf85264 Add keyboard shortcuts 2025-11-21 13:37:05 +01:00
Wendelin
4b9a716df7 Merge branch 'dev' of github.com:home-assistant/frontend into add-automation-element-by-target 2025-11-21 13:01:37 +01:00
Wendelin
e319d116dc add code regions 2025-11-21 12:21:08 +01:00
Wendelin
c8b1fd074c TARGET_SEPARATOR to data 2025-11-21 12:06:33 +01:00
Wendelin
ec9bf7dd94 Fix item target 2025-11-21 12:04:36 +01:00
Wendelin
6d01949ff5 Add target to items 2025-11-21 12:03:25 +01:00
Wendelin
8a8d7dc77a add regions 2025-11-21 11:54:55 +01:00
Wendelin
5fc629f5c5 optimize render code 2025-11-21 11:48:42 +01:00
Wendelin
d070754bb9 Fix load conditions 2025-11-21 08:56:25 +01:00
Wendelin
a2a0f900a8 Add action service lists 2025-11-20 16:19:31 +01:00
Wendelin
b18d3bff21 fix narrow title and subtitle 2025-11-20 16:05:18 +01:00
Wendelin
b06b51d03d Fix mobile scroll 2025-11-20 15:50:51 +01:00
Wendelin
022343c113 Add memoize rendering 2025-11-20 15:30:10 +01:00
Wendelin
5ca10f4a38 Fix reset after search select 2025-11-20 15:12:04 +01:00
Wendelin
33af5b6a08 Fix domain tree items 2025-11-20 14:54:56 +01:00
Wendelin
0c89415c1c Fix font weight 2025-11-20 14:50:49 +01:00
Wendelin
bd6b2c685c Rebuild tree with unassigned 2025-11-20 14:33:32 +01:00
Wendelin
cdb37edc5f add sub unassigned 2025-11-19 17:47:37 +01:00
Wendelin
d68b1571ae Rearrange unassigned 2025-11-19 16:06:18 +01:00
Wendelin
a274727713 Update webawesome 2025-11-19 16:04:36 +01:00
Wendelin
58a1aac131 remove some usage of hass 2025-11-19 15:39:28 +01:00
Wendelin
4cdbda25ed Increase dialog size 2025-11-19 14:48:24 +01:00
Wendelin
618c6e1ef2 change ha-wa-dialog large width 2025-11-19 14:47:53 +01:00
Wendelin
10b308b906 Use core triggers for target 2025-11-19 14:31:11 +01:00
Wendelin
563c6f0fbf Load tree via frontend 2025-11-19 11:58:49 +01:00
Wendelin
1dc7f0ab73 Fix scroll styles 2025-11-19 09:31:57 +01:00
Wendelin
5c8954b714 Fix search 2025-11-19 09:29:52 +01:00
Wendelin
e506e4b082 Add search 2025-11-18 17:27:45 +01:00
Wendelin
9628d69998 Fix sort 2025-11-18 09:49:43 +01:00
Wendelin
350d8e7a49 remove todo 2025-11-18 09:21:40 +01:00
Wendelin
61a5b607e0 Fix show more button 2025-11-18 09:19:51 +01:00
Wendelin
2f3a2b8418 Add unassigned section 2025-11-17 15:57:19 +01:00
Wendelin
12c1e4eec4 Add mobile max height 50percent 2025-11-17 14:25:16 +01:00
Wendelin
b704b621f2 Fix hidden addFromTarget 2025-11-17 10:41:40 +01:00
Wendelin
0eb993a8df Merge branch 'dev' of github.com:home-assistant/frontend into add-automation-element-by-target 2025-11-17 09:55:31 +01:00
Wendelin
cda97766ac also hide narrow target selector when entity is active 2025-11-17 09:47:16 +01:00
Wendelin
e6936a9294 Add mobile view 2025-11-13 15:56:14 +01:00
Wendelin
5ad73287a2 Merge branch 'dev' of github.com:home-assistant/frontend into add-automation-element-by-target 2025-11-13 09:07:37 +01:00
Wendelin
591b464508 Add devices and entities to tree 2025-11-12 17:00:26 +01:00
Wendelin
acf963d38c Merge branch 'dev' of github.com:home-assistant/frontend into add-automation-element-by-target 2025-11-12 15:30:41 +01:00
Wendelin
68f383c293 Use areas and floors in add from target picker 2025-11-12 08:22:35 +01:00
Wendelin
0a25d8106c Merge branch 'dev' of github.com:home-assistant/frontend into add-automation-element-by-target 2025-11-11 09:11:48 +01:00
Wendelin
5c3cf17df9 Merge branch 'dev' of github.com:home-assistant/frontend into add-automation-element-by-target 2025-11-10 17:15:28 +01:00
Wendelin
e905fa6f23 Add tree 2025-11-10 17:12:13 +01:00
78 changed files with 7189 additions and 1552 deletions

View File

@@ -52,7 +52,7 @@
"@fullcalendar/list": "6.1.19", "@fullcalendar/list": "6.1.19",
"@fullcalendar/luxon3": "6.1.19", "@fullcalendar/luxon3": "6.1.19",
"@fullcalendar/timegrid": "6.1.19", "@fullcalendar/timegrid": "6.1.19",
"@home-assistant/webawesome": "3.0.0", "@home-assistant/webawesome": "3.0.0-ha.0",
"@lezer/highlight": "1.2.3", "@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.0.9", "@lit-labs/motion": "1.0.9",
"@lit-labs/observers": "2.0.6", "@lit-labs/observers": "2.0.6",
@@ -112,7 +112,7 @@
"google-timezones-json": "1.2.0", "google-timezones-json": "1.2.0",
"gulp-zopfli-green": "6.0.2", "gulp-zopfli-green": "6.0.2",
"hls.js": "1.6.14", "hls.js": "1.6.14",
"home-assistant-js-websocket": "9.5.0", "home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2", "idb-keyval": "6.2.2",
"intl-messageformat": "10.7.18", "intl-messageformat": "10.7.18",
"js-yaml": "4.1.1", "js-yaml": "4.1.1",

View File

@@ -2,8 +2,8 @@
import { genClientId } from "home-assistant-js-websocket"; import { genClientId } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit"; import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit"; import { html, LitElement, nothing } from "lit";
import { keyed } from "lit/directives/keyed";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { keyed } from "lit/directives/keyed";
import type { LocalizeFunc } from "../common/translations/localize"; import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-alert"; import "../components/ha-alert";
import "../components/ha-button"; import "../components/ha-button";
@@ -118,6 +118,9 @@ export class HaAuthFlow extends LitElement {
display: block; display: block;
margin-top: 16px; margin-top: 16px;
} }
.action ha-button {
width: 100%;
}
</style> </style>
<form>${this._renderForm()}</form> <form>${this._renderForm()}</form>
`; `;

View File

@@ -0,0 +1,84 @@
import {
mdiAmpersand,
mdiClockOutline,
mdiCodeBraces,
mdiDevices,
mdiGateOr,
mdiIdentifier,
mdiMapMarkerRadius,
mdiNotEqualVariant,
mdiNumeric,
mdiStateMachine,
mdiWeatherSunny,
} from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { until } from "lit/directives/until";
import { computeDomain } from "../common/entity/compute_domain";
import { conditionIcon, FALLBACK_DOMAIN_ICONS } from "../data/icons";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-svg-icon";
export const CONDITION_ICONS = {
device: mdiDevices,
and: mdiAmpersand,
or: mdiGateOr,
not: mdiNotEqualVariant,
state: mdiStateMachine,
numeric_state: mdiNumeric,
sun: mdiWeatherSunny,
template: mdiCodeBraces,
time: mdiClockOutline,
trigger: mdiIdentifier,
zone: mdiMapMarkerRadius,
};
@customElement("ha-condition-icon")
export class HaConditionIcon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public condition?: string;
@property() public icon?: string;
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
}
if (!this.condition) {
return nothing;
}
if (!this.hass) {
return this._renderFallback();
}
const icon = conditionIcon(this.hass, this.condition).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
}
private _renderFallback() {
const domain = computeDomain(this.condition!);
return html`
<ha-svg-icon
.path=${CONDITION_ICONS[this.condition!] ||
FALLBACK_DOMAIN_ICONS[domain]}
></ha-svg-icon>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-condition-icon": HaConditionIcon;
}
}

View File

@@ -75,11 +75,15 @@ export class HaDialogHeader extends LitElement {
font-size: var(--ha-font-size-xl); font-size: var(--ha-font-size-xl);
line-height: var(--ha-line-height-condensed); line-height: var(--ha-line-height-condensed);
font-weight: var(--ha-font-weight-medium); font-weight: var(--ha-font-weight-medium);
color: var(--ha-dialog-header-title-color, var(--primary-text-color));
} }
.header-subtitle { .header-subtitle {
font-size: var(--ha-font-size-m); font-size: var(--ha-font-size-m);
line-height: var(--ha-line-height-normal); line-height: var(--ha-line-height-normal);
color: var(--secondary-text-color); color: var(
--ha-dialog-header-subtitle-color,
var(--secondary-text-color)
);
} }
@media all and (min-width: 450px) and (min-height: 500px) { @media all and (min-width: 450px) and (min-height: 500px) {
.header-bar { .header-bar {

View File

@@ -209,6 +209,7 @@ export class HaExpansionPanel extends LitElement {
::slotted([slot="header"]) { ::slotted([slot="header"]) {
flex: 1; flex: 1;
overflow-wrap: anywhere; overflow-wrap: anywhere;
color: var(--primary-text-color);
} }
.container { .container {

View File

@@ -6,7 +6,7 @@ import {
mdiHomeFloor3, mdiHomeFloor3,
mdiHomeFloorNegative1, mdiHomeFloorNegative1,
} from "@mdi/js"; } from "@mdi/js";
import { LitElement, html } from "lit"; import { LitElement, html, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import type { FloorRegistryEntry } from "../data/floor_registry"; import type { FloorRegistryEntry } from "../data/floor_registry";
import "./ha-icon"; import "./ha-icon";
@@ -48,7 +48,7 @@ export const floorDefaultIcon = (floor: Pick<FloorRegistryEntry, "level">) => {
@customElement("ha-floor-icon") @customElement("ha-floor-icon")
export class HaFloorIcon extends LitElement { export class HaFloorIcon extends LitElement {
@property({ attribute: false }) public floor!: Pick< @property({ attribute: false }) public floor?: Pick<
FloorRegistryEntry, FloorRegistryEntry,
"icon" | "level" "icon" | "level"
>; >;
@@ -56,6 +56,9 @@ export class HaFloorIcon extends LitElement {
@property() public icon?: string; @property() public icon?: string;
protected render() { protected render() {
if (!this.floor) {
return nothing;
}
if (this.floor.icon) { if (this.floor.icon) {
return html`<ha-icon .icon=${this.floor.icon}></ha-icon>`; return html`<ha-icon .icon=${this.floor.icon}></ha-icon>`;
} }

View File

@@ -66,7 +66,7 @@ export class HaIconOverflowMenu extends LitElement {
.path=${item.path} .path=${item.path}
></ha-svg-icon> ></ha-svg-icon>
${item.label} ${item.label}
</ha-md-menu-item> ` </ha-md-menu-item>`
)} )}
</ha-md-button-menu>` </ha-md-button-menu>`
: html` : html`
@@ -103,6 +103,7 @@ export class HaIconOverflowMenu extends LitElement {
:host { :host {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
cursor: initial;
} }
div[role="separator"] { div[role="separator"] {
border-right: 1px solid var(--divider-color); border-right: 1px solid var(--divider-color);

View File

@@ -27,6 +27,7 @@ export interface DisplayItem {
label: string; label: string;
description?: string; description?: string;
disableSorting?: boolean; disableSorting?: boolean;
disableHiding?: boolean;
} }
export interface DisplayValue { export interface DisplayValue {
@@ -101,6 +102,7 @@ export class HaItemDisplayEditor extends LitElement {
icon, icon,
iconPath, iconPath,
disableSorting, disableSorting,
disableHiding,
} = item; } = item;
return html` return html`
<ha-md-list-item <ha-md-list-item
@@ -155,18 +157,21 @@ export class HaItemDisplayEditor extends LitElement {
</div> </div>
` `
: nothing} : nothing}
<ha-icon-button ${!isVisible || !disableHiding
.path=${isVisible ? mdiEye : mdiEyeOff} ? html`<ha-icon-button
slot="end" .path=${isVisible ? mdiEye : mdiEyeOff}
.label=${this.hass.localize( slot="end"
`ui.components.items-display-editor.${isVisible ? "hide" : "show"}`, .label=${this.hass.localize(
{ `ui.components.items-display-editor.${isVisible ? "hide" : "show"}`,
label: label, {
} label: label,
)} }
.value=${value} )}
@click=${this._toggle} .value=${value}
></ha-icon-button> @click=${this._toggle}
.disabled=${disableHiding || false}
></ha-icon-button>`
: nothing}
${isVisible && !disableSorting ${isVisible && !disableSorting
? html` ? html`
<ha-svg-icon <ha-svg-icon

View File

@@ -154,7 +154,10 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
} }
return this._getLabelsMemoized( return this._getLabelsMemoized(
this.hass, this.hass.states,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this._labels, this._labels,
this.includeDomains, this.includeDomains,
this.excludeDomains, this.excludeDomains,

View File

@@ -36,6 +36,11 @@ export class HaMdMenuItem extends MenuItemEl {
::slotted([slot="headline"]) { ::slotted([slot="headline"]) {
text-wrap: nowrap; text-wrap: nowrap;
} }
:host([disabled]) {
opacity: 1;
--md-menu-item-label-text-color: var(--disabled-text-color);
--md-menu-item-leading-icon-color: var(--disabled-text-color);
}
`, `,
]; ];
} }

View File

@@ -6,7 +6,7 @@ import { fireEvent } from "../common/dom/fire_event";
import { titleCase } from "../common/string/title-case"; import { titleCase } from "../common/string/title-case";
import { fetchConfig } from "../data/lovelace/config/types"; import { fetchConfig } from "../data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../data/lovelace/config/view"; import type { LovelaceViewRawConfig } from "../data/lovelace/config/view";
import { getDefaultPanelUrlPath } from "../data/panel"; import { getPanelIcon, getPanelTitle } from "../data/panel";
import type { HomeAssistant, PanelInfo, ValueChangedEvent } from "../types"; import type { HomeAssistant, PanelInfo, ValueChangedEvent } from "../types";
import "./ha-combo-box"; import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box"; import type { HaComboBox } from "./ha-combo-box";
@@ -43,13 +43,8 @@ const createViewNavigationItem = (
const createPanelNavigationItem = (hass: HomeAssistant, panel: PanelInfo) => ({ const createPanelNavigationItem = (hass: HomeAssistant, panel: PanelInfo) => ({
path: `/${panel.url_path}`, path: `/${panel.url_path}`,
icon: panel.icon ?? "mdi:view-dashboard", icon: getPanelIcon(panel) || "mdi:view-dashboard",
title: title: getPanelTitle(hass, panel) || "",
panel.url_path === getDefaultPanelUrlPath(hass)
? hass.localize("panel.states")
: hass.localize(`panel.${panel.title}`) ||
panel.title ||
(panel.url_path ? titleCase(panel.url_path) : ""),
}); });
@customElement("ha-navigation-picker") @customElement("ha-navigation-picker")

View File

@@ -192,7 +192,7 @@ export class HaPickerComboBox extends LitElement {
@focus=${this._focusList} @focus=${this._focusList}
@visibilityChanged=${this._visibilityChanged} @visibilityChanged=${this._visibilityChanged}
> >
</lit-virtualizer> `; </lit-virtualizer>`;
} }
private _renderSectionButtons() { private _renderSectionButtons() {

View File

@@ -0,0 +1,28 @@
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-section-title")
class HaSectionTitle extends LitElement {
protected render() {
return html`<slot></slot>`;
}
static styles = css`
:host {
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-1) var(--ha-space-2);
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
min-height: var(--ha-space-6);
display: flex;
align-items: center;
box-sizing: border-box;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-section-title": HaSectionTitle;
}
}

View File

@@ -1,22 +1,13 @@
import { import {
mdiBell, mdiBell,
mdiCalendar,
mdiCellphoneCog, mdiCellphoneCog,
mdiChartBox,
mdiClipboardList,
mdiCog, mdiCog,
mdiFormatListBulletedType,
mdiHammer,
mdiLightningBolt,
mdiMenu, mdiMenu,
mdiMenuOpen, mdiMenuOpen,
mdiPlayBoxMultiple,
mdiTooltipAccount,
mdiViewDashboard,
} from "@mdi/js"; } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit"; import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { import {
customElement, customElement,
eventOptions, eventOptions,
@@ -33,7 +24,14 @@ import { computeRTL } from "../common/util/compute_rtl";
import { throttle } from "../common/util/throttle"; import { throttle } from "../common/util/throttle";
import { subscribeFrontendUserData } from "../data/frontend"; import { subscribeFrontendUserData } from "../data/frontend";
import type { ActionHandlerDetail } from "../data/lovelace/action_handler"; import type { ActionHandlerDetail } from "../data/lovelace/action_handler";
import { getDefaultPanelUrlPath } from "../data/panel"; import {
FIXED_PANELS,
getDefaultPanelUrlPath,
getPanelIcon,
getPanelIconPath,
getPanelTitle,
SHOW_AFTER_SPACER_PANELS,
} from "../data/panel";
import type { PersistentNotification } from "../data/persistent_notification"; import type { PersistentNotification } from "../data/persistent_notification";
import { subscribeNotifications } from "../data/persistent_notification"; import { subscribeNotifications } from "../data/persistent_notification";
import { subscribeRepairsIssueRegistry } from "../data/repairs"; import { subscribeRepairsIssueRegistry } from "../data/repairs";
@@ -54,8 +52,6 @@ import "./ha-spinner";
import "./ha-svg-icon"; import "./ha-svg-icon";
import "./user/ha-user-badge"; import "./user/ha-user-badge";
const SHOW_AFTER_SPACER = ["config", "developer-tools"];
const SUPPORT_SCROLL_IF_NEEDED = "scrollIntoViewIfNeeded" in document.body; const SUPPORT_SCROLL_IF_NEEDED = "scrollIntoViewIfNeeded" in document.body;
const SORT_VALUE_URL_PATHS = { const SORT_VALUE_URL_PATHS = {
@@ -67,18 +63,6 @@ const SORT_VALUE_URL_PATHS = {
config: 11, config: 11,
}; };
export const PANEL_ICONS = {
calendar: mdiCalendar,
"developer-tools": mdiHammer,
energy: mdiLightningBolt,
history: mdiChartBox,
logbook: mdiFormatListBulletedType,
lovelace: mdiViewDashboard,
map: mdiTooltipAccount,
"media-browser": mdiPlayBoxMultiple,
todo: mdiClipboardList,
};
const panelSorter = ( const panelSorter = (
reverseSort: string[], reverseSort: string[],
defaultPanel: string, defaultPanel: string,
@@ -155,16 +139,23 @@ export const computePanels = memoizeOne(
const beforeSpacer: PanelInfo[] = []; const beforeSpacer: PanelInfo[] = [];
const afterSpacer: PanelInfo[] = []; const afterSpacer: PanelInfo[] = [];
Object.values(panels).forEach((panel) => { const allPanels = Object.values(panels).filter(
(panel) => !FIXED_PANELS.includes(panel.url_path)
);
allPanels.forEach((panel) => {
const isDefaultPanel = panel.url_path === defaultPanel;
if ( if (
hiddenPanels.includes(panel.url_path) || !isDefaultPanel &&
(!panel.title && panel.url_path !== defaultPanel) || (!panel.title ||
(panel.default_visible === false && hiddenPanels.includes(panel.url_path) ||
!panelsOrder.includes(panel.url_path)) (panel.default_visible === false &&
!panelsOrder.includes(panel.url_path)))
) { ) {
return; return;
} }
(SHOW_AFTER_SPACER.includes(panel.url_path) (SHOW_AFTER_SPACER_PANELS.includes(panel.url_path)
? afterSpacer ? afterSpacer
: beforeSpacer : beforeSpacer
).push(panel); ).push(panel);
@@ -251,10 +242,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
return nothing; return nothing;
} }
// Show the supervisor as being part of configuration const selectedPanel = this.hass.panelUrl;
const selectedPanel = this.route.path?.startsWith("/hassio/")
? "config"
: this.hass.panelUrl;
// prettier-ignore // prettier-ignore
return html` return html`
@@ -397,9 +385,9 @@ class HaSidebar extends SubscribeMixin(LitElement) {
private _renderAllPanels(selectedPanel: string) { private _renderAllPanels(selectedPanel: string) {
if (!this._panelOrder || !this._hiddenPanels) { if (!this._panelOrder || !this._hiddenPanels) {
return html` return html`
<ha-fade-in .delay=${500} <ha-fade-in .delay=${500}>
><ha-spinner size="small"></ha-spinner <ha-spinner size="small"></ha-spinner>
></ha-fade-in> </ha-fade-in>
`; `;
} }
@@ -413,7 +401,6 @@ class HaSidebar extends SubscribeMixin(LitElement) {
this.hass.locale this.hass.locale
); );
// prettier-ignore
return html` return html`
<ha-md-list <ha-md-list
class="ha-scrollbar" class="ha-scrollbar"
@@ -422,61 +409,42 @@ class HaSidebar extends SubscribeMixin(LitElement) {
@scroll=${this._listboxScroll} @scroll=${this._listboxScroll}
@keydown=${this._listboxKeydown} @keydown=${this._listboxKeydown}
> >
${this._renderPanels(beforeSpacer, selectedPanel, defaultPanel)} ${this._renderPanels(beforeSpacer, selectedPanel)}
${this._renderSpacer()} ${this._renderSpacer()}
${this._renderPanels(afterSpacer, selectedPanel, defaultPanel)} ${this._renderPanels(afterSpacer, selectedPanel)}
${this._renderExternalConfiguration()} ${this.hass.user?.is_admin
? this._renderConfiguration(selectedPanel)
: this._renderExternalConfiguration()}
</ha-md-list> </ha-md-list>
`; `;
} }
private _renderPanels( private _renderPanels(panels: PanelInfo[], selectedPanel: string) {
panels: PanelInfo[],
selectedPanel: string,
defaultPanel: string
) {
return panels.map((panel) => return panels.map((panel) =>
this._renderPanel( this._renderPanel(panel, panel.url_path === selectedPanel)
panel.url_path,
panel.url_path === defaultPanel
? panel.title || this.hass.localize("panel.states")
: this.hass.localize(`panel.${panel.title}`) || panel.title,
panel.icon,
panel.url_path === defaultPanel && !panel.icon
? PANEL_ICONS.lovelace
: panel.url_path in PANEL_ICONS
? PANEL_ICONS[panel.url_path]
: undefined,
selectedPanel
)
); );
} }
private _renderPanel( private _renderPanel(panel: PanelInfo, isSelected: boolean) {
urlPath: string, const title = getPanelTitle(this.hass, panel);
title: string | null, const urlPath = panel.url_path;
icon: string | null | undefined, const icon = getPanelIcon(panel);
iconPath: string | null | undefined, const iconPath = getPanelIconPath(panel);
selectedPanel: string
) { return html`
return urlPath === "config" <ha-md-list-item
? this._renderConfiguration(title, selectedPanel) .href=${`/${urlPath}`}
: html` type="link"
<ha-md-list-item class=${classMap({ selected: isSelected })}
.href=${`/${urlPath}`} @mouseenter=${this._itemMouseEnter}
type="link" @mouseleave=${this._itemMouseLeave}
class=${classMap({ >
selected: selectedPanel === urlPath, ${iconPath
})} ? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
@mouseenter=${this._itemMouseEnter} : html`<ha-icon slot="start" .icon=${icon}></ha-icon>`}
@mouseleave=${this._itemMouseLeave} <span class="item-text" slot="headline">${title}</span>
> </ha-md-list-item>
${iconPath `;
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
: html`<ha-icon slot="start" .icon=${icon}></ha-icon>`}
<span class="item-text" slot="headline">${title}</span>
</ha-md-list-item>
`;
} }
private _renderDivider() { private _renderDivider() {
@@ -487,10 +455,15 @@ class HaSidebar extends SubscribeMixin(LitElement) {
return html`<div class="spacer" disabled></div>`; return html`<div class="spacer" disabled></div>`;
} }
private _renderConfiguration(title: string | null, selectedPanel: string) { private _renderConfiguration(selectedPanel: string) {
if (!this.hass.user?.is_admin) {
return nothing;
}
const isSelected =
selectedPanel === "config" || this.route.path?.startsWith("/hassio/");
return html` return html`
<ha-md-list-item <ha-md-list-item
class="configuration${selectedPanel === "config" ? " selected" : ""}" class="configuration ${classMap({ selected: isSelected })}"
type="button" type="button"
href="/config" href="/config"
@mouseenter=${this._itemMouseEnter} @mouseenter=${this._itemMouseEnter}
@@ -504,15 +477,17 @@ class HaSidebar extends SubscribeMixin(LitElement) {
${this._updatesCount + this._issuesCount} ${this._updatesCount + this._issuesCount}
</span> </span>
` `
: ""} : nothing}
<span class="item-text" slot="headline">${title}</span> <span class="item-text" slot="headline"
>${this.hass.localize("panel.config")}</span
>
${this.alwaysExpand && (this._updatesCount > 0 || this._issuesCount > 0) ${this.alwaysExpand && (this._updatesCount > 0 || this._issuesCount > 0)
? html` ? html`
<span class="badge" slot="end" <span class="badge" slot="end"
>${this._updatesCount + this._issuesCount}</span >${this._updatesCount + this._issuesCount}</span
> >
` `
: ""} : nothing}
</ha-md-list-item> </ha-md-list-item>
`; `;
} }
@@ -535,19 +510,20 @@ class HaSidebar extends SubscribeMixin(LitElement) {
? html` ? html`
<span class="badge" slot="start"> ${notificationCount} </span> <span class="badge" slot="start"> ${notificationCount} </span>
` `
: ""} : nothing}
<span class="item-text" slot="headline" <span class="item-text" slot="headline"
>${this.hass.localize("ui.notification_drawer.title")}</span >${this.hass.localize("ui.notification_drawer.title")}</span
> >
${this.alwaysExpand && notificationCount > 0 ${this.alwaysExpand && notificationCount > 0
? html`<span class="badge" slot="end">${notificationCount}</span>` ? html`<span class="badge" slot="end">${notificationCount}</span>`
: ""} : nothing}
</ha-md-list-item> </ha-md-list-item>
`; `;
} }
private _renderUserItem(selectedPanel: string) { private _renderUserItem(selectedPanel: string) {
const isRTL = computeRTL(this.hass); const isRTL = computeRTL(this.hass);
const isSelected = selectedPanel === "profile";
return html` return html`
<ha-md-list-item <ha-md-list-item
@@ -555,7 +531,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
type="link" type="link"
class=${classMap({ class=${classMap({
user: true, user: true,
selected: selectedPanel === "profile", selected: isSelected,
rtl: isRTL, rtl: isRTL,
})} })}
@mouseenter=${this._itemMouseEnter} @mouseenter=${this._itemMouseEnter}
@@ -566,31 +542,30 @@ class HaSidebar extends SubscribeMixin(LitElement) {
.user=${this.hass.user} .user=${this.hass.user}
.hass=${this.hass} .hass=${this.hass}
></ha-user-badge> ></ha-user-badge>
<span class="item-text" slot="headline">
<span class="item-text" slot="headline" ${this.hass.user ? this.hass.user.name : ""}
>${this.hass.user ? this.hass.user.name : ""}</span </span>
>
</ha-md-list-item> </ha-md-list-item>
`; `;
} }
private _renderExternalConfiguration() { private _renderExternalConfiguration() {
return html`${!this.hass.user?.is_admin && if (!this.hass.auth.external?.config.hasSettingsScreen) {
this.hass.auth.external?.config.hasSettingsScreen return nothing;
? html` }
<ha-md-list-item return html`
@click=${this._handleExternalAppConfiguration} <ha-md-list-item
type="button" @click=${this._handleExternalAppConfiguration}
@mouseenter=${this._itemMouseEnter} type="button"
@mouseleave=${this._itemMouseLeave} @mouseenter=${this._itemMouseEnter}
> @mouseleave=${this._itemMouseLeave}
<ha-svg-icon slot="start" .path=${mdiCellphoneCog}></ha-svg-icon> >
<span class="item-text" slot="headline"> <ha-svg-icon slot="start" .path=${mdiCellphoneCog}></ha-svg-icon>
${this.hass.localize("ui.sidebar.external_app_configuration")} <span class="item-text" slot="headline">
</span> ${this.hass.localize("ui.sidebar.external_app_configuration")}
</ha-md-list-item> </span>
` </ha-md-list-item>
: ""}`; `;
} }
private _handleExternalAppConfiguration(ev: Event) { private _handleExternalAppConfiguration(ev: Event) {

View File

@@ -0,0 +1,178 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HomeAssistant } from "../types";
import { subscribeLabFeatures } from "../data/labs";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
interface Snowflake {
id: number;
left: number;
size: number;
duration: number;
delay: number;
blur: number;
}
@customElement("ha-snowflakes")
export class HaSnowflakes extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _enabled = false;
@state() private _snowflakes: Snowflake[] = [];
private _maxSnowflakes = 50;
public hassSubscribe() {
return [
subscribeLabFeatures(this.hass!.connection, (features) => {
this._enabled =
features.find(
(f) =>
f.domain === "frontend" && f.preview_feature === "winter_mode"
)?.enabled ?? false;
}),
];
}
private _generateSnowflakes() {
if (!this._enabled) {
this._snowflakes = [];
return;
}
const snowflakes: Snowflake[] = [];
for (let i = 0; i < this._maxSnowflakes; i++) {
snowflakes.push({
id: i,
left: Math.random() * 100, // Random position from 0-100%
size: Math.random() * 12 + 8, // Random size between 8-20px
duration: Math.random() * 8 + 8, // Random duration between 8-16s
delay: Math.random() * 8, // Random delay between 0-8s
blur: Math.random() * 1, // Random blur between 0-1px
});
}
this._snowflakes = snowflakes;
}
protected willUpdate(changedProps: Map<string, unknown>) {
super.willUpdate(changedProps);
if (changedProps.has("_enabled")) {
this._generateSnowflakes();
}
}
protected render() {
if (!this._enabled) {
return nothing;
}
const isDark = this.hass?.themes.darkMode ?? false;
return html`
<div class="snowflakes ${isDark ? "dark" : "light"}" aria-hidden="true">
${this._snowflakes.map(
(flake) => html`
<div
class="snowflake ${this.narrow && flake.id >= 30
? "hide-narrow"
: ""}"
style="
left: ${flake.left}%;
font-size: ${flake.size}px;
animation-duration: ${flake.duration}s;
animation-delay: ${flake.delay}s;
filter: blur(${flake.blur}px);
"
>
</div>
`
)}
</div>
`;
}
static readonly styles = css`
:host {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9999;
overflow: hidden;
}
.snowflakes {
position: absolute;
top: -10%;
left: 0;
width: 100%;
height: 110%;
pointer-events: none;
}
.snowflake {
position: absolute;
top: -10%;
opacity: 0.7;
user-select: none;
pointer-events: none;
animation: fall linear infinite;
}
.light .snowflake {
color: #00bcd4;
text-shadow:
0 0 5px #00bcd4,
0 0 10px #00e5ff;
}
.dark .snowflake {
color: #fff;
text-shadow:
0 0 5px rgba(255, 255, 255, 0.8),
0 0 10px rgba(255, 255, 255, 0.5);
}
.snowflake.hide-narrow {
display: none;
}
@keyframes fall {
0% {
transform: translateY(-10vh) translateX(0);
}
25% {
transform: translateY(30vh) translateX(10px);
}
50% {
transform: translateY(60vh) translateX(-10px);
}
75% {
transform: translateY(85vh) translateX(10px);
}
100% {
transform: translateY(120vh) translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.snowflake {
animation: none;
display: none;
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-snowflakes": HaSnowflakes;
}
}

View File

@@ -30,6 +30,7 @@ import {
areaMeetsFilter, areaMeetsFilter,
deviceMeetsFilter, deviceMeetsFilter,
entityRegMeetsFilter, entityRegMeetsFilter,
getTargetComboBoxItemType,
type TargetType, type TargetType,
type TargetTypeFloorless, type TargetTypeFloorless,
} from "../data/target"; } from "../data/target";
@@ -47,7 +48,6 @@ import "./ha-tree-indicator";
import "./target-picker/ha-target-picker-item-group"; import "./target-picker/ha-target-picker-item-group";
import "./target-picker/ha-target-picker-value-chip"; import "./target-picker/ha-target-picker-value-chip";
const EMPTY_SEARCH = "___EMPTY_SEARCH___";
const SEPARATOR = "________"; const SEPARATOR = "________";
const CREATE_ID = "___create-new-entity___"; const CREATE_ID = "___create-new-entity___";
@@ -634,35 +634,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
return undefined; return undefined;
} }
private _getRowType = (
item:
| PickerComboBoxItem
| (FloorComboBoxItem & { last?: boolean | undefined })
| EntityComboBoxItem
| DevicePickerItem
) => {
if (
(item as FloorComboBoxItem).type === "area" ||
(item as FloorComboBoxItem).type === "floor"
) {
return (item as FloorComboBoxItem).type;
}
if ("domain" in item) {
return "device";
}
if ("stateObj" in item) {
return "entity";
}
if (item.id === EMPTY_SEARCH) {
return "empty";
}
return "label";
};
private _sectionTitleFunction = ({ private _sectionTitleFunction = ({
firstIndex, firstIndex,
lastIndex, lastIndex,
@@ -686,7 +657,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
return undefined; return undefined;
} }
const type = this._getRowType(firstItem as PickerComboBoxItem); const type = getTargetComboBoxItemType(firstItem as PickerComboBoxItem);
const translationType: const translationType:
| "areas" | "areas"
| "entities" | "entities"
@@ -858,7 +829,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
if (!filterType || filterType === "label") { if (!filterType || filterType === "label") {
let labels = this._getLabelsMemoized( let labels = this._getLabelsMemoized(
this.hass, this.hass.states,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this._labelRegistry, this._labelRegistry,
includeDomains, includeDomains,
undefined, undefined,
@@ -974,7 +948,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
return nothing; return nothing;
} }
const type = this._getRowType(item); const type = getTargetComboBoxItemType(item);
let hasFloor = false; let hasFloor = false;
let rtl = false; let rtl = false;
let showEntityId = false; let showEntityId = false;

View File

@@ -235,7 +235,7 @@ export class HaWaDialog extends LitElement {
} }
:host([width="large"]) wa-dialog { :host([width="large"]) wa-dialog {
--width: min(var(--ha-dialog-width-lg, 720px), var(--full-width)); --width: min(var(--ha-dialog-width-lg, 1024px), var(--full-width));
} }
:host([width="full"]) wa-dialog { :host([width="full"]) wa-dialog {

View File

@@ -5,6 +5,7 @@ export interface AnalyticsPreferences {
diagnostics?: boolean; diagnostics?: boolean;
usage?: boolean; usage?: boolean;
statistics?: boolean; statistics?: boolean;
snapshots?: boolean;
} }
export interface Analytics { export interface Analytics {

View File

@@ -21,11 +21,52 @@ export interface FloorComboBoxItem extends PickerComboBoxItem {
area?: AreaRegistryEntry; area?: AreaRegistryEntry;
} }
export interface FloorNestedComboBoxItem extends PickerComboBoxItem {
floor?: FloorRegistryEntry;
areas: FloorComboBoxItem[];
}
export interface UnassignedAreasFloorComboBoxItem extends PickerComboBoxItem {
areas: FloorComboBoxItem[];
}
export interface AreaFloorValue { export interface AreaFloorValue {
id: string; id: string;
type: "floor" | "area"; type: "floor" | "area";
} }
export const getAreasNestedInFloors = (
states: HomeAssistant["states"],
haFloors: HomeAssistant["floors"],
haAreas: HomeAssistant["areas"],
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
formatId: (value: AreaFloorValue) => string,
includeDomains?: string[],
excludeDomains?: string[],
includeDeviceClasses?: string[],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc,
excludeAreas?: string[],
excludeFloors?: string[]
) =>
getAreasAndFloorsItems(
states,
haFloors,
haAreas,
haDevices,
haEntities,
formatId,
includeDomains,
excludeDomains,
includeDeviceClasses,
deviceFilter,
entityFilter,
excludeAreas,
excludeFloors,
true
) as (FloorNestedComboBoxItem | UnassignedAreasFloorComboBoxItem)[];
export const getAreasAndFloors = ( export const getAreasAndFloors = (
states: HomeAssistant["states"], states: HomeAssistant["states"],
haFloors: HomeAssistant["floors"], haFloors: HomeAssistant["floors"],
@@ -40,7 +81,43 @@ export const getAreasAndFloors = (
entityFilter?: HaEntityPickerEntityFilterFunc, entityFilter?: HaEntityPickerEntityFilterFunc,
excludeAreas?: string[], excludeAreas?: string[],
excludeFloors?: string[] excludeFloors?: string[]
): FloorComboBoxItem[] => { ) =>
getAreasAndFloorsItems(
states,
haFloors,
haAreas,
haDevices,
haEntities,
formatId,
includeDomains,
excludeDomains,
includeDeviceClasses,
deviceFilter,
entityFilter,
excludeAreas,
excludeFloors
) as FloorComboBoxItem[];
const getAreasAndFloorsItems = (
states: HomeAssistant["states"],
haFloors: HomeAssistant["floors"],
haAreas: HomeAssistant["areas"],
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
formatId: (value: AreaFloorValue) => string,
includeDomains?: string[],
excludeDomains?: string[],
includeDeviceClasses?: string[],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc,
excludeAreas?: string[],
excludeFloors?: string[],
nested = false
): (
| FloorComboBoxItem
| FloorNestedComboBoxItem
| UnassignedAreasFloorComboBoxItem
)[] => {
const floors = Object.values(haFloors); const floors = Object.values(haFloors);
const areas = Object.values(haAreas); const areas = Object.values(haAreas);
const devices = Object.values(haDevices); const devices = Object.values(haDevices);
@@ -181,7 +258,11 @@ export const getAreasAndFloors = (
const hierarchy = getAreasFloorHierarchy(floors, outputAreas); const hierarchy = getAreasFloorHierarchy(floors, outputAreas);
const items: FloorComboBoxItem[] = []; const items: (
| FloorComboBoxItem
| FloorNestedComboBoxItem
| UnassignedAreasFloorComboBoxItem
)[] = [];
hierarchy.floors.forEach((f) => { hierarchy.floors.forEach((f) => {
const floor = haFloors[f.id]; const floor = haFloors[f.id];
@@ -196,7 +277,7 @@ export const getAreasAndFloors = (
}) })
.flat(); .flat();
items.push({ const floorItem: FloorComboBoxItem | FloorNestedComboBoxItem = {
id: formatId({ id: floor.floor_id, type: "floor" }), id: formatId({ id: floor.floor_id, type: "floor" }),
type: "floor", type: "floor",
primary: floorName, primary: floorName,
@@ -208,41 +289,53 @@ export const getAreasAndFloors = (
...floor.aliases, ...floor.aliases,
...areaSearchLabels, ...areaSearchLabels,
], ],
}); };
items.push( items.push(floorItem);
...floorAreas.map((area) => {
const areaName = computeAreaName(area);
return {
id: formatId({ id: area.area_id, type: "area" }),
type: "area" as const,
primary: areaName || area.area_id,
area: area,
icon: area.icon || undefined,
search_labels: [
area.area_id,
...(areaName ? [areaName] : []),
...area.aliases,
],
};
})
);
});
items.push( const floorAreasItems = floorAreas.map((area) => {
...hierarchy.areas.map((areaId) => { const areaName = computeAreaName(area);
const area = haAreas[areaId];
const areaName = computeAreaName(area) || area.area_id;
return { return {
id: formatId({ id: area.area_id, type: "area" }), id: formatId({ id: area.area_id, type: "area" }),
type: "area" as const, type: "area" as const,
primary: areaName, primary: areaName || area.area_id,
area: area, area: area,
icon: area.icon || undefined, icon: area.icon || undefined,
search_labels: [area.area_id, areaName, ...area.aliases], search_labels: [
area.area_id,
...(areaName ? [areaName] : []),
...area.aliases,
],
}; };
}) });
);
if (nested && floor) {
(floorItem as unknown as FloorNestedComboBoxItem).areas = floorAreasItems;
} else {
items.push(...floorAreasItems);
}
});
const unassignedAreaItems = hierarchy.areas.map((areaId) => {
const area = haAreas[areaId];
const areaName = computeAreaName(area) || area.area_id;
return {
id: formatId({ id: area.area_id, type: "area" }),
type: "area" as const,
primary: areaName,
area: area,
icon: area.icon || undefined,
search_labels: [area.area_id, areaName, ...area.aliases],
};
});
if (nested && unassignedAreaItems.length) {
items.push({
areas: unassignedAreaItems,
} as UnassignedAreasFloorComboBoxItem);
} else {
items.push(...unassignedAreaItems);
}
return items; return items;
}; };

View File

@@ -1,7 +1,10 @@
import { stringCompare } from "../common/string/compare"; import { stringCompare } from "../common/string/compare";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import type { DeviceRegistryEntry } from "./device_registry"; import type { DeviceRegistryEntry } from "./device_registry";
import type { EntityRegistryEntry } from "./entity_registry"; import type {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "./entity_registry";
import type { RegistryEntry } from "./registry"; import type { RegistryEntry } from "./registry";
export { subscribeAreaRegistry } from "./ws-area_registry"; export { subscribeAreaRegistry } from "./ws-area_registry";
@@ -18,7 +21,10 @@ export interface AreaRegistryEntry extends RegistryEntry {
temperature_entity_id: string | null; temperature_entity_id: string | null;
} }
export type AreaEntityLookup = Record<string, EntityRegistryEntry[]>; export type AreaEntityLookup = Record<
string,
(EntityRegistryEntry | EntityRegistryDisplayEntry)[]
>;
export type AreaDeviceLookup = Record<string, DeviceRegistryEntry[]>; export type AreaDeviceLookup = Record<string, DeviceRegistryEntry[]>;
@@ -69,11 +75,17 @@ export const reorderAreaRegistryEntries = (
}); });
export const getAreaEntityLookup = ( export const getAreaEntityLookup = (
entities: EntityRegistryEntry[] entities: (EntityRegistryEntry | EntityRegistryDisplayEntry)[],
filterHidden = false
): AreaEntityLookup => { ): AreaEntityLookup => {
const areaEntityLookup: AreaEntityLookup = {}; const areaEntityLookup: AreaEntityLookup = {};
for (const entity of entities) { for (const entity of entities) {
if (!entity.area_id) { if (
!entity.area_id ||
(filterHidden &&
((entity as EntityRegistryDisplayEntry).hidden ||
(entity as EntityRegistryEntry).hidden_by))
) {
continue; continue;
} }
if (!(entity.area_id in areaEntityLookup)) { if (!(entity.area_id in areaEntityLookup)) {

View File

@@ -214,6 +214,8 @@ export interface PipelineRun {
stage: "ready" | "wake_word" | "stt" | "intent" | "tts" | "done" | "error"; stage: "ready" | "wake_word" | "stt" | "intent" | "tts" | "done" | "error";
run: PipelineRunStartEvent["data"]; run: PipelineRunStartEvent["data"];
error?: PipelineErrorEvent["data"]; error?: PipelineErrorEvent["data"];
started: Date;
finished?: Date;
wake_word?: PipelineWakeWordStartEvent["data"] & wake_word?: PipelineWakeWordStartEvent["data"] &
Partial<PipelineWakeWordEndEvent["data"]> & { done: boolean }; Partial<PipelineWakeWordEndEvent["data"]> & { done: boolean };
stt?: PipelineSTTStartEvent["data"] & stt?: PipelineSTTStartEvent["data"] &
@@ -235,6 +237,7 @@ export const processEvent = (
stage: "ready", stage: "ready",
run: event.data, run: event.data,
events: [event], events: [event],
started: new Date(event.timestamp),
}; };
return run; return run;
} }
@@ -290,9 +293,14 @@ export const processEvent = (
tts: { ...run.tts!, ...event.data, done: true }, tts: { ...run.tts!, ...event.data, done: true },
}; };
} else if (event.type === "run-end") { } else if (event.type === "run-end") {
run = { ...run, stage: "done" }; run = { ...run, finished: new Date(event.timestamp), stage: "done" };
} else if (event.type === "error") { } else if (event.type === "error") {
run = { ...run, stage: "error", error: event.data }; run = {
...run,
finished: new Date(event.timestamp),
stage: "error",
error: event.data,
};
} else { } else {
run = { ...run }; run = { ...run };
} }

View File

@@ -10,6 +10,7 @@ import type { LocalizeKeys } from "../common/translations/localize";
import { createSearchParam } from "../common/url/search-params"; import { createSearchParam } from "../common/url/search-params";
import type { Context, HomeAssistant } from "../types"; import type { Context, HomeAssistant } from "../types";
import type { BlueprintInput } from "./blueprint"; import type { BlueprintInput } from "./blueprint";
import type { ConditionDescription } from "./condition";
import { CONDITION_BUILDING_BLOCKS } from "./condition"; import { CONDITION_BUILDING_BLOCKS } from "./condition";
import type { DeviceCondition, DeviceTrigger } from "./device_automation"; import type { DeviceCondition, DeviceTrigger } from "./device_automation";
import type { Action, Field, MODES } from "./script"; import type { Action, Field, MODES } from "./script";
@@ -236,6 +237,12 @@ interface BaseCondition {
condition: string; condition: string;
alias?: string; alias?: string;
enabled?: boolean; enabled?: boolean;
options?: Record<string, unknown>;
}
export interface PlatformCondition extends BaseCondition {
condition: Exclude<string, LegacyCondition["condition"]>;
target?: HassServiceTarget;
} }
export interface LogicalCondition extends BaseCondition { export interface LogicalCondition extends BaseCondition {
@@ -320,7 +327,7 @@ export type AutomationElementGroup = Record<
{ icon?: string; members?: AutomationElementGroup } { icon?: string; members?: AutomationElementGroup }
>; >;
export type Condition = export type LegacyCondition =
| StateCondition | StateCondition
| NumericStateCondition | NumericStateCondition
| SunCondition | SunCondition
@@ -331,6 +338,8 @@ export type Condition =
| LogicalCondition | LogicalCondition
| TriggerCondition; | TriggerCondition;
export type Condition = LegacyCondition | PlatformCondition;
export type ConditionWithShorthand = export type ConditionWithShorthand =
| Condition | Condition
| ShorthandAndConditionList | ShorthandAndConditionList
@@ -608,6 +617,7 @@ export interface ConditionSidebarConfig extends BaseSidebarConfig {
insertAfter: (value: Condition | Condition[]) => boolean; insertAfter: (value: Condition | Condition[]) => boolean;
toggleYamlMode: () => void; toggleYamlMode: () => void;
config: Condition; config: Condition;
description?: ConditionDescription;
yamlMode: boolean; yamlMode: boolean;
uiSupported: boolean; uiSupported: boolean;
} }

View File

@@ -18,7 +18,14 @@ import {
} from "../common/string/format-list"; } from "../common/string/format-list";
import { hasTemplate } from "../common/string/has-template"; import { hasTemplate } from "../common/string/has-template";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import type { Condition, ForDict, LegacyTrigger, Trigger } from "./automation"; import type {
Condition,
ForDict,
LegacyCondition,
LegacyTrigger,
Trigger,
} from "./automation";
import { getConditionDomain, getConditionObjectId } from "./condition";
import type { DeviceCondition, DeviceTrigger } from "./device_automation"; import type { DeviceCondition, DeviceTrigger } from "./device_automation";
import { import {
localizeDeviceAutomationCondition, localizeDeviceAutomationCondition,
@@ -896,6 +903,39 @@ const tryDescribeCondition = (
} }
} }
const description = describeLegacyCondition(
condition as LegacyCondition,
hass,
entityRegistry
);
if (description) {
return description;
}
const conditionType = condition.condition;
const domain = getConditionDomain(condition.condition);
const type = getConditionObjectId(condition.condition);
return (
hass.localize(
`component.${domain}.conditions.${type}.description_configured`
) ||
hass.localize(
`ui.panel.config.automation.editor.conditions.type.${conditionType as LegacyCondition["condition"]}.label`
) ||
hass.localize(
`ui.panel.config.automation.editor.conditions.unknown_condition`
)
);
};
const describeLegacyCondition = (
condition: LegacyCondition,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[]
) => {
if (condition.condition === "or") { if (condition.condition === "or") {
const conditions = ensureArray(condition.conditions); const conditions = ensureArray(condition.conditions);
@@ -1287,12 +1327,5 @@ const tryDescribeCondition = (
); );
} }
return ( return undefined;
hass.localize(
`ui.panel.config.automation.editor.conditions.type.${condition.condition}.label`
) ||
hass.localize(
`ui.panel.config.automation.editor.conditions.unknown_condition`
)
);
}; };

228
src/data/chat_log.ts Normal file
View File

@@ -0,0 +1,228 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
export const enum ChatLogEventType {
INITIAL_STATE = "initial_state",
CREATED = "created",
UPDATED = "updated",
DELETED = "deleted",
CONTENT_ADDED = "content_added",
}
export interface ChatLogAttachment {
media_content_id: string;
mime_type: string;
path: string;
}
export interface ChatLogSystemContent {
role: "system";
content: string;
created: Date;
}
export interface ChatLogUserContent {
role: "user";
content: string;
created: Date;
attachments?: ChatLogAttachment[];
}
export interface ChatLogAssistantContent {
role: "assistant";
agent_id: string;
created: Date;
content?: string;
thinking_content?: string;
tool_calls?: any[];
}
export interface ChatLogToolResultContent {
role: "tool_result";
agent_id: string;
tool_call_id: string;
tool_name: string;
tool_result: any;
created: Date;
}
export type ChatLogContent =
| ChatLogSystemContent
| ChatLogUserContent
| ChatLogAssistantContent
| ChatLogToolResultContent;
export interface ChatLog {
conversation_id: string;
continue_conversation: boolean;
content: ChatLogContent[];
created: Date;
}
// Internal wire format types (not exported)
interface ChatLogSystemContentWire {
role: "system";
content: string;
created: string;
}
interface ChatLogUserContentWire {
role: "user";
content: string;
created: string;
attachments?: ChatLogAttachment[];
}
interface ChatLogAssistantContentWire {
role: "assistant";
agent_id: string;
created: string;
content?: string;
thinking_content?: string;
tool_calls?: {
tool_name: string;
tool_args: Record<string, any>;
id: string;
external: boolean;
}[];
}
interface ChatLogToolResultContentWire {
role: "tool_result";
agent_id: string;
tool_call_id: string;
tool_name: string;
tool_result: any;
created: string;
}
type ChatLogContentWire =
| ChatLogSystemContentWire
| ChatLogUserContentWire
| ChatLogAssistantContentWire
| ChatLogToolResultContentWire;
interface ChatLogWire {
conversation_id: string;
continue_conversation: boolean;
content: ChatLogContentWire[];
created: string;
}
const processContent = (content: ChatLogContentWire): ChatLogContent => ({
...content,
created: new Date(content.created),
});
const processChatLog = (chatLog: ChatLogWire): ChatLog => ({
...chatLog,
created: new Date(chatLog.created),
content: chatLog.content.map(processContent),
});
interface ChatLogInitialStateEvent {
event_type: ChatLogEventType.INITIAL_STATE;
data: ChatLogWire;
}
interface ChatLogIndexInitialStateEvent {
event_type: ChatLogEventType.INITIAL_STATE;
data: ChatLogWire[];
}
interface ChatLogCreatedEvent {
conversation_id: string;
event_type: ChatLogEventType.CREATED;
data: ChatLogWire;
}
interface ChatLogUpdatedEvent {
conversation_id: string;
event_type: ChatLogEventType.UPDATED;
data: { chat_log: ChatLogWire };
}
interface ChatLogDeletedEvent {
conversation_id: string;
event_type: ChatLogEventType.DELETED;
data: ChatLogWire;
}
interface ChatLogContentAddedEvent {
conversation_id: string;
event_type: ChatLogEventType.CONTENT_ADDED;
data: { content: ChatLogContentWire };
}
type ChatLogSubscriptionEvent =
| ChatLogInitialStateEvent
| ChatLogUpdatedEvent
| ChatLogDeletedEvent
| ChatLogContentAddedEvent;
type ChatLogIndexSubscriptionEvent =
| ChatLogIndexInitialStateEvent
| ChatLogCreatedEvent
| ChatLogDeletedEvent;
export const subscribeChatLog = (
hass: HomeAssistant,
conversationId: string,
callback: (chatLog: ChatLog | null) => void
): Promise<UnsubscribeFunc> => {
let chatLog: ChatLog | null = null;
return hass.connection.subscribeMessage<ChatLogSubscriptionEvent>(
(event) => {
if (event.event_type === ChatLogEventType.INITIAL_STATE) {
chatLog = processChatLog(event.data);
callback(chatLog);
} else if (event.event_type === ChatLogEventType.CONTENT_ADDED) {
if (chatLog) {
chatLog = {
...chatLog,
content: [...chatLog.content, processContent(event.data.content)],
};
callback(chatLog);
}
} else if (event.event_type === ChatLogEventType.UPDATED) {
chatLog = processChatLog(event.data.chat_log);
callback(chatLog);
} else if (event.event_type === ChatLogEventType.DELETED) {
chatLog = null;
callback(null);
}
},
{
type: "conversation/chat_log/subscribe",
conversation_id: conversationId,
}
);
};
export const subscribeChatLogIndex = (
hass: HomeAssistant,
callback: (chatLogs: ChatLog[]) => void
): Promise<UnsubscribeFunc> => {
let chatLogs: ChatLog[] = [];
return hass.connection.subscribeMessage<ChatLogIndexSubscriptionEvent>(
(event) => {
if (event.event_type === ChatLogEventType.INITIAL_STATE) {
chatLogs = event.data.map(processChatLog);
callback(chatLogs);
} else if (event.event_type === ChatLogEventType.CREATED) {
chatLogs = [...chatLogs, processChatLog(event.data)];
callback(chatLogs);
} else if (event.event_type === ChatLogEventType.DELETED) {
chatLogs = chatLogs.filter(
(chatLog) => chatLog.conversation_id !== event.conversation_id
);
callback(chatLogs);
}
},
{
type: "conversation/chat_log/subscribe_index",
}
);
};

View File

@@ -1,38 +1,15 @@
import { import { mdiMapClock, mdiShape } from "@mdi/js";
mdiAmpersand, import { computeDomain } from "../common/entity/compute_domain";
mdiClockOutline, import { computeObjectId } from "../common/entity/compute_object_id";
mdiCodeBraces, import type { HomeAssistant } from "../types";
mdiDevices,
mdiGateOr,
mdiIdentifier,
mdiMapClock,
mdiMapMarkerRadius,
mdiNotEqualVariant,
mdiNumeric,
mdiShape,
mdiStateMachine,
mdiWeatherSunny,
} from "@mdi/js";
import type { AutomationElementGroupCollection } from "./automation"; import type { AutomationElementGroupCollection } from "./automation";
import type { Selector, TargetSelector } from "./selector";
export const CONDITION_ICONS = {
device: mdiDevices,
and: mdiAmpersand,
or: mdiGateOr,
not: mdiNotEqualVariant,
state: mdiStateMachine,
numeric_state: mdiNumeric,
sun: mdiWeatherSunny,
template: mdiCodeBraces,
time: mdiClockOutline,
trigger: mdiIdentifier,
zone: mdiMapMarkerRadius,
};
export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [ export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
{ {
groups: { groups: {
device: {}, device: {},
dynamicGroups: {},
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } }, entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
time_location: { time_location: {
icon: mdiMapClock, icon: mdiMapClock,
@@ -62,3 +39,33 @@ export const COLLAPSIBLE_CONDITION_ELEMENTS = [
"ha-automation-condition-not", "ha-automation-condition-not",
"ha-automation-condition-or", "ha-automation-condition-or",
]; ];
export interface ConditionDescription {
target?: TargetSelector["target"];
fields: Record<
string,
{
example?: string | boolean | number;
default?: unknown;
required?: boolean;
selector?: Selector;
context?: Record<string, string>;
}
>;
}
export type ConditionDescriptions = Record<string, ConditionDescription>;
export const subscribeConditions = (
hass: HomeAssistant,
callback: (conditions: ConditionDescriptions) => void
) =>
hass.connection.subscribeMessage<ConditionDescriptions>(callback, {
type: "condition_platforms/subscribe",
});
export const getConditionDomain = (condition: string) =>
condition.includes(".") ? computeDomain(condition) : condition;
export const getConditionObjectId = (condition: string) =>
condition.includes(".") ? computeObjectId(condition) : "_";

View File

@@ -50,7 +50,11 @@ export type DeviceEntityDisplayLookup = Record<
EntityRegistryDisplayEntry[] EntityRegistryDisplayEntry[]
>; >;
export type DeviceEntityLookup = Record<string, EntityRegistryEntry[]>; export type DeviceEntityLookup<
T extends EntityRegistryEntry | EntityRegistryDisplayEntry =
| EntityRegistryEntry
| EntityRegistryDisplayEntry,
> = Record<string, T[]>;
export interface DeviceRegistryEntryMutableParams { export interface DeviceRegistryEntryMutableParams {
area_id?: string | null; area_id?: string | null;
@@ -107,11 +111,17 @@ export const sortDeviceRegistryByName = (
); );
export const getDeviceEntityLookup = ( export const getDeviceEntityLookup = (
entities: EntityRegistryEntry[] entities: (EntityRegistryEntry | EntityRegistryDisplayEntry)[],
filterHidden = false
): DeviceEntityLookup => { ): DeviceEntityLookup => {
const deviceEntityLookup: DeviceEntityLookup = {}; const deviceEntityLookup: DeviceEntityLookup = {};
for (const entity of entities) { for (const entity of entities) {
if (!entity.device_id) { if (
!entity.device_id ||
(filterHidden &&
((entity as EntityRegistryDisplayEntry).hidden ||
(entity as EntityRegistryEntry).hidden_by))
) {
continue; continue;
} }
if (!(entity.device_id in deviceEntityLookup)) { if (!(entity.device_id in deviceEntityLookup)) {

View File

@@ -775,6 +775,7 @@ export const getEnergyDataCollection = (
hass.locale, hass.locale,
hass.config hass.config
); );
collection.refresh();
scheduleUpdatePeriod(); scheduleUpdatePeriod();
}, },
addHours( addHours(

View File

@@ -7,8 +7,8 @@ export interface CoreFrontendUserData {
} }
export interface SidebarFrontendUserData { export interface SidebarFrontendUserData {
panelOrder: string[]; panelOrder?: string[];
hiddenPanels: string[]; hiddenPanels?: string[];
} }
export interface CoreFrontendSystemData { export interface CoreFrontendSystemData {

View File

@@ -60,6 +60,7 @@ import type {
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg"; import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
import { getTriggerDomain, getTriggerObjectId } from "./trigger"; import { getTriggerDomain, getTriggerObjectId } from "./trigger";
import { getConditionDomain, getConditionObjectId } from "./condition";
/** Icon to use when no icon specified for service. */ /** Icon to use when no icon specified for service. */
export const DEFAULT_SERVICE_ICON = mdiRoomService; export const DEFAULT_SERVICE_ICON = mdiRoomService;
@@ -138,15 +139,25 @@ const resources: {
all?: Promise<Record<string, TriggerIcons>>; all?: Promise<Record<string, TriggerIcons>>;
domains: Record<string, TriggerIcons | Promise<TriggerIcons>>; domains: Record<string, TriggerIcons | Promise<TriggerIcons>>;
}; };
conditions: {
all?: Promise<Record<string, ConditionIcons>>;
domains: Record<string, ConditionIcons | Promise<ConditionIcons>>;
};
} = { } = {
entity: {}, entity: {},
entity_component: {}, entity_component: {},
services: { domains: {} }, services: { domains: {} },
triggers: { domains: {} }, triggers: { domains: {} },
conditions: { domains: {} },
}; };
interface IconResources< interface IconResources<
T extends ComponentIcons | PlatformIcons | ServiceIcons | TriggerIcons, T extends
| ComponentIcons
| PlatformIcons
| ServiceIcons
| TriggerIcons
| ConditionIcons,
> { > {
resources: Record<string, T>; resources: Record<string, T>;
} }
@@ -195,17 +206,24 @@ type TriggerIcons = Record<
{ trigger: string; sections?: Record<string, string> } { trigger: string; sections?: Record<string, string> }
>; >;
type ConditionIcons = Record<
string,
{ condition: string; sections?: Record<string, string> }
>;
export type IconCategory = export type IconCategory =
| "entity" | "entity"
| "entity_component" | "entity_component"
| "services" | "services"
| "triggers"; | "triggers"
| "conditions";
interface CategoryType { interface CategoryType {
entity: PlatformIcons; entity: PlatformIcons;
entity_component: ComponentIcons; entity_component: ComponentIcons;
services: ServiceIcons; services: ServiceIcons;
triggers: TriggerIcons; triggers: TriggerIcons;
conditions: ConditionIcons;
} }
export const getHassIcons = async <T extends IconCategory>( export const getHassIcons = async <T extends IconCategory>(
@@ -327,6 +345,13 @@ export const getTriggerIcons = async (
): Promise<TriggerIcons | Record<string, TriggerIcons> | undefined> => ): Promise<TriggerIcons | Record<string, TriggerIcons> | undefined> =>
getCategoryIcons(hass, "triggers", domain, force); getCategoryIcons(hass, "triggers", domain, force);
export const getConditionIcons = async (
hass: HomeAssistant,
domain?: string,
force = false
): Promise<ConditionIcons | Record<string, ConditionIcons> | undefined> =>
getCategoryIcons(hass, "conditions", domain, force);
// Cache for sorted range keys // Cache for sorted range keys
const sortedRangeCache = new WeakMap<Record<string, string>, number[]>(); const sortedRangeCache = new WeakMap<Record<string, string>, number[]>();
@@ -526,6 +551,25 @@ export const triggerIcon = async (
return icon; return icon;
}; };
export const conditionIcon = async (
hass: HomeAssistant,
condition: string
): Promise<string | undefined> => {
let icon: string | undefined;
const domain = getConditionDomain(condition);
const conditionIcons = await getConditionIcons(hass, domain);
if (conditionIcons) {
const conditionName = getConditionObjectId(condition);
const condIcon = conditionIcons[conditionName] as ConditionIcons[string];
icon = condIcon?.condition;
}
if (!icon) {
icon = await domainIcon(hass, domain);
}
return icon;
};
export const serviceIcon = async ( export const serviceIcon = async (
hass: HomeAssistant, hass: HomeAssistant,
service: string service: string

View File

@@ -1,8 +1,8 @@
import type { Connection } from "home-assistant-js-websocket"; import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket"; import { createCollection } from "home-assistant-js-websocket";
import type { LocalizeFunc } from "../common/translations/localize"; import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types";
import { debounce } from "../common/util/debounce"; import { debounce } from "../common/util/debounce";
import type { HomeAssistant } from "../types";
export const integrationsWithPanel = { export const integrationsWithPanel = {
bluetooth: "config/bluetooth", bluetooth: "config/bluetooth",
@@ -25,6 +25,8 @@ export type IntegrationType =
| "entity" | "entity"
| "system"; | "system";
export type DomainManifestLookup = Record<string, IntegrationManifest>;
export interface IntegrationManifest { export interface IntegrationManifest {
is_built_in: boolean; is_built_in: boolean;
overwrites_built_in?: boolean; overwrites_built_in?: boolean;

View File

@@ -101,7 +101,10 @@ export const deleteLabelRegistryEntry = (
}); });
export const getLabels = ( export const getLabels = (
hass: HomeAssistant, hassStates: HomeAssistant["states"],
hassAreas: HomeAssistant["areas"],
hassDevices: HomeAssistant["devices"],
hassEntities: HomeAssistant["entities"],
labels?: LabelRegistryEntry[], labels?: LabelRegistryEntry[],
includeDomains?: string[], includeDomains?: string[],
excludeDomains?: string[], excludeDomains?: string[],
@@ -115,8 +118,8 @@ export const getLabels = (
return []; return [];
} }
const devices = Object.values(hass.devices); const devices = Object.values(hassDevices);
const entities = Object.values(hass.entities); const entities = Object.values(hassEntities);
let deviceEntityLookup: DeviceEntityDisplayLookup = {}; let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined; let inputDevices: DeviceRegistryEntry[] | undefined;
@@ -170,7 +173,7 @@ export const getLabels = (
return false; return false;
} }
return deviceEntityLookup[device.id].some((entity) => { return deviceEntityLookup[device.id].some((entity) => {
const stateObj = hass.states[entity.entity_id]; const stateObj = hassStates[entity.entity_id];
if (!stateObj) { if (!stateObj) {
return false; return false;
} }
@@ -181,8 +184,9 @@ export const getLabels = (
}); });
}); });
inputEntities = inputEntities!.filter((entity) => { inputEntities = inputEntities!.filter((entity) => {
const stateObj = hass.states[entity.entity_id]; const stateObj = hassStates[entity.entity_id];
return ( return (
stateObj &&
stateObj.attributes.device_class && stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class) includeDeviceClasses.includes(stateObj.attributes.device_class)
); );
@@ -200,7 +204,7 @@ export const getLabels = (
return false; return false;
} }
return deviceEntityLookup[device.id].some((entity) => { return deviceEntityLookup[device.id].some((entity) => {
const stateObj = hass.states[entity.entity_id]; const stateObj = hassStates[entity.entity_id];
if (!stateObj) { if (!stateObj) {
return false; return false;
} }
@@ -208,7 +212,7 @@ export const getLabels = (
}); });
}); });
inputEntities = inputEntities!.filter((entity) => { inputEntities = inputEntities!.filter((entity) => {
const stateObj = hass.states[entity.entity_id]; const stateObj = hassStates[entity.entity_id];
if (!stateObj) { if (!stateObj) {
return false; return false;
} }
@@ -245,8 +249,8 @@ export const getLabels = (
if (areaIds) { if (areaIds) {
areaIds.forEach((areaId) => { areaIds.forEach((areaId) => {
const area = hass.areas[areaId]; const area = hassAreas[areaId];
area.labels.forEach((label) => usedLabels.add(label)); area?.labels.forEach((label) => usedLabels.add(label));
}); });
} }

View File

@@ -1,3 +1,15 @@
import {
mdiAccount,
mdiCalendar,
mdiChartBox,
mdiClipboardList,
mdiFormatListBulletedType,
mdiHammer,
mdiLightningBolt,
mdiPlayBoxMultiple,
mdiTooltipAccount,
mdiViewDashboard,
} from "@mdi/js";
import type { HomeAssistant, PanelInfo } from "../types"; import type { HomeAssistant, PanelInfo } from "../types";
/** Panel to show when no panel is picked. */ /** Panel to show when no panel is picked. */
@@ -60,7 +72,7 @@ export const getPanelTitleFromUrlPath = (
return getPanelTitle(hass, panel); return getPanelTitle(hass, panel);
}; };
export const getPanelIcon = (panel: PanelInfo): string | null => { export const getPanelIcon = (panel: PanelInfo): string | undefined => {
if (!panel.icon) { if (!panel.icon) {
switch (panel.component_name) { switch (panel.component_name) {
case "profile": case "profile":
@@ -70,5 +82,24 @@ export const getPanelIcon = (panel: PanelInfo): string | null => {
} }
} }
return panel.icon; return panel.icon || undefined;
}; };
export const PANEL_ICON_PATHS = {
calendar: mdiCalendar,
"developer-tools": mdiHammer,
energy: mdiLightningBolt,
history: mdiChartBox,
logbook: mdiFormatListBulletedType,
lovelace: mdiViewDashboard,
profile: mdiAccount,
map: mdiTooltipAccount,
"media-browser": mdiPlayBoxMultiple,
todo: mdiClipboardList,
};
export const getPanelIconPath = (panel: PanelInfo): string | undefined =>
PANEL_ICON_PATHS[panel.url_path];
export const FIXED_PANELS = ["profile", "config"];
export const SHOW_AFTER_SPACER_PANELS = ["developer-tools"];

View File

@@ -1,15 +1,30 @@
import type { HassServiceTarget } from "home-assistant-js-websocket"; import type { HassServiceTarget } from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain"; import { computeDomain } from "../common/entity/compute_domain";
import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker"; import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker";
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import type { FloorComboBoxItem } from "./area_floor";
import type { AreaRegistryEntry } from "./area_registry"; import type { AreaRegistryEntry } from "./area_registry";
import type { DeviceRegistryEntry } from "./device_registry"; import type { DevicePickerItem, DeviceRegistryEntry } from "./device_registry";
import type { HaEntityPickerEntityFilterFunc } from "./entity"; import type { HaEntityPickerEntityFilterFunc } from "./entity";
import type { EntityRegistryDisplayEntry } from "./entity_registry"; import type {
EntityComboBoxItem,
EntityRegistryDisplayEntry,
} from "./entity_registry";
export const TARGET_SEPARATOR = "________";
export type TargetType = "entity" | "device" | "area" | "label" | "floor"; export type TargetType = "entity" | "device" | "area" | "label" | "floor";
export type TargetTypeFloorless = Exclude<TargetType, "floor">; export type TargetTypeFloorless = Exclude<TargetType, "floor">;
export interface SingleHassServiceTarget {
entity_id?: string;
device_id?: string;
area_id?: string;
floor_id?: string;
label_id?: string;
}
export interface ExtractFromTargetResult { export interface ExtractFromTargetResult {
missing_areas: string[]; missing_areas: string[];
missing_devices: string[]; missing_devices: string[];
@@ -35,6 +50,28 @@ export const extractFromTarget = async (
target, target,
}); });
export const getTriggersForTarget = async (
callWS: HomeAssistant["callWS"],
target: HassServiceTarget,
expandGroup = true
) =>
callWS<string[]>({
type: "get_triggers_for_target",
target,
expand_group: expandGroup,
});
export const getServicesForTarget = async (
callWS: HomeAssistant["callWS"],
target: HassServiceTarget,
expandGroup = true
) =>
callWS<string[]>({
type: "get_services_for_target",
target,
expand_group: expandGroup,
});
export const areaMeetsFilter = ( export const areaMeetsFilter = (
area: AreaRegistryEntry, area: AreaRegistryEntry,
devices: HomeAssistant["devices"], devices: HomeAssistant["devices"],
@@ -162,3 +199,32 @@ export const entityRegMeetsFilter = (
} }
return true; return true;
}; };
export const getTargetComboBoxItemType = (
item:
| PickerComboBoxItem
| (FloorComboBoxItem & { last?: boolean | undefined })
| EntityComboBoxItem
| DevicePickerItem
) => {
if (
(item as FloorComboBoxItem).type === "area" ||
(item as FloorComboBoxItem).type === "floor"
) {
return (item as FloorComboBoxItem).type;
}
if ("domain" in item) {
return "device";
}
if ("stateObj" in item) {
return "entity";
}
if (item.id === "___EMPTY_SEARCH___") {
return "empty";
}
return "label";
};

View File

@@ -75,7 +75,8 @@ export type TranslationCategory =
| "preview_features" | "preview_features"
| "selector" | "selector"
| "services" | "services"
| "triggers"; | "triggers"
| "conditions";
export const subscribeTranslationPreferences = ( export const subscribeTranslationPreferences = (
hass: HomeAssistant, hass: HomeAssistant,

View File

@@ -2,14 +2,15 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import "../../components/ha-alert"; import "../../components/ha-alert";
import "../../components/ha-icon"; import "../../components/ha-icon";
import "../../components/ha-list-item"; import "../../components/ha-md-list-item";
import "../../components/ha-spinner"; import "../../components/ha-spinner";
import type { import type {
ExternalEntityAddToActions,
ExternalEntityAddToAction, ExternalEntityAddToAction,
ExternalEntityAddToActions,
} from "../../external_app/external_messaging"; } from "../../external_app/external_messaging";
import { showToast } from "../../util/toast"; import { showToast } from "../../util/toast";
import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
@customElement("ha-more-info-add-to") @customElement("ha-more-info-add-to")
@@ -51,6 +52,7 @@ export class HaMoreInfoAddTo extends LitElement {
app_payload: action.app_payload, app_payload: action.app_payload,
}, },
}); });
fireEvent(this, "add-to-action-selected");
} catch (err: any) { } catch (err: any) {
showToast(this, { showToast(this, {
message: this.hass.localize( message: this.hass.localize(
@@ -91,19 +93,18 @@ export class HaMoreInfoAddTo extends LitElement {
<div class="actions-list"> <div class="actions-list">
${this._externalActions.actions.map( ${this._externalActions.actions.map(
(action) => html` (action) => html`
<ha-list-item <ha-md-list-item
graphic="icon" type="button"
.disabled=${!action.enabled} .disabled=${!action.enabled}
.action=${action} .action=${action}
.twoline=${!!action.details}
@click=${this._actionSelected} @click=${this._actionSelected}
> >
<ha-icon slot="start" .icon=${action.mdi_icon}></ha-icon>
<span>${action.name}</span> <span>${action.name}</span>
${action.details ${action.details
? html`<span slot="secondary">${action.details}</span>` ? html`<span slot="supporting-text">${action.details}</span>`
: nothing} : nothing}
<ha-icon slot="graphic" .icon=${action.mdi_icon}></ha-icon> </ha-md-list-item>
</ha-list-item>
` `
)} )}
</div> </div>
@@ -129,15 +130,6 @@ export class HaMoreInfoAddTo extends LitElement {
flex-direction: column; flex-direction: column;
} }
ha-list-item {
cursor: pointer;
}
ha-list-item[disabled] {
cursor: not-allowed;
opacity: 0.5;
}
ha-icon { ha-icon {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -149,4 +141,8 @@ declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"ha-more-info-add-to": HaMoreInfoAddTo; "ha-more-info-add-to": HaMoreInfoAddTo;
} }
interface HASSDomEvents {
"add-to-action-selected": undefined;
}
} }

View File

@@ -645,6 +645,7 @@ export class MoreInfoDialog extends LitElement {
<ha-more-info-add-to <ha-more-info-add-to
.hass=${this.hass} .hass=${this.hass}
.entityId=${entityId} .entityId=${entityId}
@add-to-action-selected=${this._goBack}
></ha-more-info-add-to> ></ha-more-info-add-to>
` `
: nothing : nothing

View File

@@ -1,5 +1,5 @@
import "@material/mwc-linear-progress/mwc-linear-progress"; import "@material/mwc-linear-progress/mwc-linear-progress";
import { mdiClose } from "@mdi/js"; import { mdiClose, mdiDotsVertical, mdiRestart } from "@mdi/js";
import { css, html, LitElement, nothing, type TemplateResult } from "lit"; import { css, html, LitElement, nothing, type TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
@@ -9,18 +9,30 @@ import "../../components/ha-dialog-header";
import "../../components/ha-fade-in"; import "../../components/ha-fade-in";
import "../../components/ha-icon-button"; import "../../components/ha-icon-button";
import "../../components/ha-items-display-editor"; import "../../components/ha-items-display-editor";
import type { DisplayValue } from "../../components/ha-items-display-editor"; import type {
DisplayItem,
DisplayValue,
} from "../../components/ha-items-display-editor";
import "../../components/ha-md-button-menu";
import "../../components/ha-md-dialog"; import "../../components/ha-md-dialog";
import type { HaMdDialog } from "../../components/ha-md-dialog"; import type { HaMdDialog } from "../../components/ha-md-dialog";
import { computePanels, PANEL_ICONS } from "../../components/ha-sidebar"; import "../../components/ha-md-menu-item";
import { computePanels } from "../../components/ha-sidebar";
import "../../components/ha-spinner"; import "../../components/ha-spinner";
import "../../components/ha-svg-icon";
import { import {
fetchFrontendUserData, fetchFrontendUserData,
saveFrontendUserData, saveFrontendUserData,
} from "../../data/frontend"; } from "../../data/frontend";
import {
getDefaultPanelUrlPath,
getPanelIcon,
getPanelIconPath,
getPanelTitle,
SHOW_AFTER_SPACER_PANELS,
} from "../../data/panel";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { showConfirmationDialog } from "../generic/show-dialog-box"; import { showConfirmationDialog } from "../generic/show-dialog-box";
import { getDefaultPanelUrlPath } from "../../data/panel";
@customElement("dialog-edit-sidebar") @customElement("dialog-edit-sidebar")
class DialogEditSidebar extends LitElement { class DialogEditSidebar extends LitElement {
@@ -105,48 +117,53 @@ class DialogEditSidebar extends LitElement {
this.hass.locale this.hass.locale
); );
// Add default hidden panels that are missing in hidden const orderSet = new Set(this._order);
const hiddenSet = new Set(this._hidden);
for (const panel of panels) { for (const panel of panels) {
if ( if (
panel.default_visible === false && panel.default_visible === false &&
!this._order.includes(panel.url_path) && !orderSet.has(panel.url_path) &&
!this._hidden.includes(panel.url_path) !hiddenSet.has(panel.url_path)
) { ) {
this._hidden.push(panel.url_path); hiddenSet.add(panel.url_path);
} }
} }
if (hiddenSet.has(defaultPanel)) {
hiddenSet.delete(defaultPanel);
}
const hiddenPanels = Array.from(hiddenSet);
const items = [ const items = [
...beforeSpacer, ...beforeSpacer,
...panels.filter((panel) => this._hidden!.includes(panel.url_path)), ...panels.filter((panel) => hiddenPanels.includes(panel.url_path)),
...afterSpacer.filter((panel) => panel.url_path !== "config"), ...afterSpacer,
].map((panel) => ({ ].map<DisplayItem>((panel) => ({
value: panel.url_path, value: panel.url_path,
label: label:
panel.url_path === defaultPanel (getPanelTitle(this.hass, panel) || panel.url_path) +
? panel.title || this.hass.localize("panel.states") `${defaultPanel === panel.url_path ? " (default)" : ""}`,
: this.hass.localize(`panel.${panel.title}`) || panel.title || "?", icon: getPanelIcon(panel),
icon: panel.icon || undefined, iconPath: getPanelIconPath(panel),
iconPath: disableSorting: SHOW_AFTER_SPACER_PANELS.includes(panel.url_path),
panel.url_path === defaultPanel && !panel.icon disableHiding: panel.url_path === defaultPanel,
? PANEL_ICONS.lovelace
: panel.url_path in PANEL_ICONS
? PANEL_ICONS[panel.url_path]
: undefined,
disableSorting: panel.url_path === "developer-tools",
})); }));
return html`<ha-items-display-editor return html`
.hass=${this.hass} <ha-items-display-editor
.value=${{ .hass=${this.hass}
order: this._order, .value=${{
hidden: this._hidden, order: this._order,
}} hidden: hiddenPanels,
.items=${items} }}
@value-changed=${this._changed} .items=${items}
dont-sort-visible @value-changed=${this._changed}
> dont-sort-visible
</ha-items-display-editor>`; >
</ha-items-display-editor>
`;
} }
protected render() { protected render() {
@@ -171,6 +188,22 @@ class DialogEditSidebar extends LitElement {
>${this.hass.localize("ui.sidebar.edit_subtitle")}</span >${this.hass.localize("ui.sidebar.edit_subtitle")}</span
>` >`
: nothing} : nothing}
<ha-md-button-menu
slot="actionItems"
positioning="popover"
anchor-corner="end-end"
menu-corner="start-end"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-md-menu-item .clickAction=${this._resetToDefaults}>
<ha-svg-icon slot="start" .path=${mdiRestart}></ha-svg-icon>
${this.hass.localize("ui.sidebar.reset_to_defaults")}
</ha-md-menu-item>
</ha-md-button-menu>
</ha-dialog-header> </ha-dialog-header>
<div slot="content" class="content">${this._renderContent()}</div> <div slot="content" class="content">${this._renderContent()}</div>
<div slot="actions"> <div slot="actions">
@@ -194,6 +227,26 @@ class DialogEditSidebar extends LitElement {
this._hidden = [...hidden]; this._hidden = [...hidden];
} }
private _resetToDefaults = async () => {
const confirmation = await showConfirmationDialog(this, {
text: this.hass.localize("ui.sidebar.reset_confirmation"),
confirmText: this.hass.localize("ui.common.reset"),
});
if (!confirmation) {
return;
}
this._order = [];
this._hidden = [];
try {
await saveFrontendUserData(this.hass.connection, "sidebar", {});
} catch (err: any) {
this._error = err.message || err;
}
this.closeDialog();
};
private async _save() { private async _save() {
if (this._migrateToUserData) { if (this._migrateToUserData) {
const confirmation = await showConfirmationDialog(this, { const confirmation = await showConfirmationDialog(this, {

View File

@@ -7,6 +7,7 @@ import { listenMediaQuery } from "../common/dom/media_query";
import { toggleAttribute } from "../common/dom/toggle_attribute"; import { toggleAttribute } from "../common/dom/toggle_attribute";
import { computeRTLDirection } from "../common/util/compute_rtl"; import { computeRTLDirection } from "../common/util/compute_rtl";
import "../components/ha-drawer"; import "../components/ha-drawer";
import "../components/ha-snowflakes";
import { showNotificationDrawer } from "../dialogs/notifications/show-notification-drawer"; import { showNotificationDrawer } from "../dialogs/notifications/show-notification-drawer";
import type { HomeAssistant, Route } from "../types"; import type { HomeAssistant, Route } from "../types";
import "./partial-panel-resolver"; import "./partial-panel-resolver";
@@ -50,6 +51,7 @@ export class HomeAssistantMain extends LitElement {
this.hass.panels && this.hass.userData && this.hass.systemData; this.hass.panels && this.hass.userData && this.hass.systemData;
return html` return html`
<ha-snowflakes .hass=${this.hass} .narrow=${this.narrow}></ha-snowflakes>
<ha-drawer <ha-drawer
.type=${sidebarNarrow ? "modal" : ""} .type=${sidebarNarrow ? "modal" : ""}
.open=${sidebarNarrow ? this._drawerOpen : false} .open=${sidebarNarrow ? this._drawerOpen : false}

View File

@@ -0,0 +1,187 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { css, html } from "lit";
import type {
CSSResultGroup,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { classMap } from "lit/directives/class-map";
import { state } from "lit/decorators";
import type { Constructor } from "../types";
const stylesArray = (styles?: CSSResultGroup | CSSResultGroup[]) =>
styles === undefined ? [] : Array.isArray(styles) ? styles : [styles];
export const ScrollableFadeMixin = <T extends Constructor<LitElement>>(
superClass: T
) => {
class ScrollableFadeClass extends superClass {
@state() protected _contentScrolled = false;
@state() protected _contentScrollable = false;
private _scrollTarget?: HTMLElement | null;
private _onScroll = (ev: Event) => {
const target = ev.currentTarget as HTMLElement;
this._contentScrolled = (target.scrollTop ?? 0) > 0;
this._updateScrollableState(target);
};
private _resize = new ResizeController(this, {
target: null,
callback: (entries) => {
const target = entries[0]?.target as HTMLElement | undefined;
if (target) {
this._updateScrollableState(target);
}
},
});
private static readonly DEFAULT_SAFE_AREA_PADDING = 16;
private static readonly DEFAULT_SCROLLABLE_ELEMENT: HTMLElement | null =
null;
protected get scrollFadeSafeAreaPadding() {
return ScrollableFadeClass.DEFAULT_SAFE_AREA_PADDING;
}
protected get scrollableElement(): HTMLElement | null {
return ScrollableFadeClass.DEFAULT_SCROLLABLE_ELEMENT;
}
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated?.(changedProperties);
this._attachScrollableElement();
}
protected updated(changedProperties: PropertyValues) {
super.updated?.(changedProperties);
this._attachScrollableElement();
}
disconnectedCallback() {
this._detachScrollableElement();
super.disconnectedCallback();
}
protected renderScrollableFades(rounded = false): TemplateResult {
return html`
<div
class=${classMap({
"fade-top": true,
rounded,
visible: this._contentScrolled,
})}
></div>
<div
class=${classMap({
"fade-bottom": true,
rounded,
visible: this._contentScrollable,
})}
></div>
`;
}
static get styles() {
const superCtor = Object.getPrototypeOf(this) as
| typeof LitElement
| undefined;
const inheritedStyles = stylesArray(
(superCtor?.styles ?? []) as CSSResultGroup | CSSResultGroup[]
);
return [
...inheritedStyles,
css`
.fade-top,
.fade-bottom {
position: absolute;
left: var(--ha-space-0);
right: var(--ha-space-0);
height: var(--ha-space-4);
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);
z-index: 100;
opacity: 0;
}
.fade-top {
top: var(--ha-space-0);
}
.fade-bottom {
bottom: var(--ha-space-0);
transform: rotate(180deg);
}
.fade-top.visible,
.fade-bottom.visible {
opacity: 1;
}
.fade-top.rounded,
.fade-bottom.rounded {
border-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
border-bottom-left-radius: var(--ha-border-radius-square);
border-bottom-right-radius: var(--ha-border-radius-square);
}
.fade-top.rounded {
border-top-left-radius: var(--ha-border-radius-square);
border-top-right-radius: var(--ha-border-radius-square);
}
.fade-bottom.rounded {
border-bottom-left-radius: var(--ha-border-radius-square);
border-bottom-right-radius: var(--ha-border-radius-square);
}
`,
];
}
private _attachScrollableElement() {
const element = this.scrollableElement;
if (element === this._scrollTarget) {
return;
}
this._detachScrollableElement();
if (!element) {
return;
}
this._scrollTarget = element;
element.addEventListener("scroll", this._onScroll, { passive: true });
this._resize.observe(element);
this._updateScrollableState(element);
}
private _detachScrollableElement() {
if (!this._scrollTarget) {
return;
}
this._scrollTarget.removeEventListener("scroll", this._onScroll);
this._resize.unobserve?.(this._scrollTarget);
this._scrollTarget = undefined;
}
private _updateScrollableState(element: HTMLElement) {
const safeAreaInsetBottom =
parseFloat(
getComputedStyle(element).getPropertyValue("--safe-area-inset-bottom")
) || 0;
const { scrollHeight = 0, clientHeight = 0, scrollTop = 0 } = element;
this._contentScrollable =
scrollHeight - clientHeight >
scrollTop + safeAreaInsetBottom + this.scrollFadeSafeAreaPadding;
}
}
return ScrollableFadeClass;
};

View File

@@ -1,19 +1,29 @@
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event"; import { fireEvent } from "../../../../../common/dom/fire_event";
import { stringCompare } from "../../../../../common/string/compare";
import { stopPropagation } from "../../../../../common/dom/stop_propagation"; import { stopPropagation } from "../../../../../common/dom/stop_propagation";
import { stringCompare } from "../../../../../common/string/compare";
import type { LocalizeFunc } from "../../../../../common/translations/localize"; import type { LocalizeFunc } from "../../../../../common/translations/localize";
import { CONDITION_ICONS } from "../../../../../components/ha-condition-icon";
import "../../../../../components/ha-list-item"; import "../../../../../components/ha-list-item";
import "../../../../../components/ha-select"; import "../../../../../components/ha-select";
import type { HaSelect } from "../../../../../components/ha-select"; import type { HaSelect } from "../../../../../components/ha-select";
import type { Condition } from "../../../../../data/automation"; import {
DYNAMIC_PREFIX,
getValueFromDynamic,
isDynamic,
type Condition,
} from "../../../../../data/automation";
import type { ConditionDescriptions } from "../../../../../data/condition";
import { import {
CONDITION_BUILDING_BLOCKS, CONDITION_BUILDING_BLOCKS,
CONDITION_ICONS, getConditionDomain,
getConditionObjectId,
subscribeConditions,
} from "../../../../../data/condition"; } from "../../../../../data/condition";
import type { Entries, HomeAssistant } from "../../../../../types"; import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../../types";
import "../../condition/ha-automation-condition-editor"; import "../../condition/ha-automation-condition-editor";
import type HaAutomationConditionEditor from "../../condition/ha-automation-condition-editor"; import type HaAutomationConditionEditor from "../../condition/ha-automation-condition-editor";
import "../../condition/types/ha-automation-condition-and"; import "../../condition/types/ha-automation-condition-and";
@@ -30,7 +40,10 @@ import "../../condition/types/ha-automation-condition-zone";
import type { ActionElement } from "../ha-automation-action-row"; import type { ActionElement } from "../ha-automation-action-row";
@customElement("ha-automation-action-condition") @customElement("ha-automation-action-condition")
export class HaConditionAction extends LitElement implements ActionElement { export class HaConditionAction
extends SubscribeMixin(LitElement)
implements ActionElement
{
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
@@ -43,6 +56,8 @@ export class HaConditionAction extends LitElement implements ActionElement {
@property({ type: Boolean, attribute: "indent" }) public indent = false; @property({ type: Boolean, attribute: "indent" }) public indent = false;
@state() private _conditionDescriptions: ConditionDescriptions = {};
@query("ha-automation-condition-editor") @query("ha-automation-condition-editor")
private _conditionEditor?: HaAutomationConditionEditor; private _conditionEditor?: HaAutomationConditionEditor;
@@ -50,6 +65,21 @@ export class HaConditionAction extends LitElement implements ActionElement {
return { condition: "state" }; return { condition: "state" };
} }
protected hassSubscribe() {
return [
subscribeConditions(this.hass, (conditions) =>
this._addConditions(conditions)
),
];
}
private _addConditions(conditions: ConditionDescriptions) {
this._conditionDescriptions = {
...this._conditionDescriptions,
...conditions,
};
}
protected render() { protected render() {
const buildingBlock = CONDITION_BUILDING_BLOCKS.includes( const buildingBlock = CONDITION_BUILDING_BLOCKS.includes(
this.action.condition this.action.condition
@@ -64,19 +94,25 @@ export class HaConditionAction extends LitElement implements ActionElement {
"ui.panel.config.automation.editor.conditions.type_select" "ui.panel.config.automation.editor.conditions.type_select"
)} )}
.disabled=${this.disabled} .disabled=${this.disabled}
.value=${this.action.condition} .value=${this.action.condition in this._conditionDescriptions
? `${DYNAMIC_PREFIX}${this.action.condition}`
: this.action.condition}
naturalMenuWidth naturalMenuWidth
@selected=${this._typeChanged} @selected=${this._typeChanged}
@closed=${stopPropagation} @closed=${stopPropagation}
> >
${this._processedTypes(this.hass.localize).map( ${this._processedTypes(
([opt, label, icon]) => html` this._conditionDescriptions,
this.hass.localize
).map(
([opt, label, condition]) => html`
<ha-list-item .value=${opt} graphic="icon"> <ha-list-item .value=${opt} graphic="icon">
${label}<ha-svg-icon ${label}
<ha-condition-icon
slot="graphic" slot="graphic"
.path=${icon} .condition=${condition}
></ha-svg-icon ></ha-condition-icon>
></ha-list-item> </ha-list-item>
` `
)} )}
</ha-select> </ha-select>
@@ -88,11 +124,14 @@ export class HaConditionAction extends LitElement implements ActionElement {
? html` ? html`
<ha-automation-condition-editor <ha-automation-condition-editor
.condition=${this.action} .condition=${this.action}
.description=${this._conditionDescriptions[this.action.condition]}
.disabled=${this.disabled} .disabled=${this.disabled}
.hass=${this.hass} .hass=${this.hass}
@value-changed=${this._conditionChanged} @value-changed=${this._conditionChanged}
.narrow=${this.narrow} .narrow=${this.narrow}
.uiSupported=${this._uiSupported(this.action.condition)} .uiSupported=${this._uiSupported(
this._getType(this.action, this._conditionDescriptions)
)}
.indent=${this.indent} .indent=${this.indent}
action action
></ha-automation-condition-editor> ></ha-automation-condition-editor>
@@ -102,19 +141,46 @@ export class HaConditionAction extends LitElement implements ActionElement {
} }
private _processedTypes = memoizeOne( private _processedTypes = memoizeOne(
(localize: LocalizeFunc): [string, string, string][] => (
(Object.entries(CONDITION_ICONS) as Entries<typeof CONDITION_ICONS>) conditionDescriptions: ConditionDescriptions,
.map( localize: LocalizeFunc
([condition, icon]) => ): [string, string, string][] => {
[ const legacy = (
condition, Object.keys(CONDITION_ICONS) as (keyof typeof CONDITION_ICONS)[]
localize( ).map(
`ui.panel.config.automation.editor.conditions.type.${condition}.label` (condition) =>
), [
icon, condition,
] as [string, string, string] localize(
) `ui.panel.config.automation.editor.conditions.type.${condition}.label`
.sort((a, b) => stringCompare(a[1], b[1], this.hass.locale.language)) ),
condition,
] as [string, string, string]
);
const platform = Object.keys(conditionDescriptions).map((condition) => {
const domain = getConditionDomain(condition);
const conditionObjId = getConditionObjectId(condition);
return [
`${DYNAMIC_PREFIX}${condition}`,
localize(`component.${domain}.conditions.${conditionObjId}.name`) ||
condition,
condition,
] as [string, string, string];
});
return [...legacy, ...platform].sort((a, b) =>
stringCompare(a[1], b[1], this.hass.locale.language)
);
}
);
private _getType = memoizeOne(
(condition: Condition, conditionDescriptions: ConditionDescriptions) => {
if (condition.condition in conditionDescriptions) {
return "platform";
}
return condition.condition;
}
); );
private _conditionChanged(ev: CustomEvent) { private _conditionChanged(ev: CustomEvent) {
@@ -132,6 +198,18 @@ export class HaConditionAction extends LitElement implements ActionElement {
return; return;
} }
if (isDynamic(type)) {
const value = getValueFromDynamic(type);
if (value !== this.action.condition) {
fireEvent(this, "value-changed", {
value: {
condition: value,
},
});
}
return;
}
const elClass = customElements.get( const elClass = customElements.get(
`ha-automation-condition-${type}` `ha-automation-condition-${type}`
) as CustomElementConstructor & { ) as CustomElementConstructor & {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,384 @@
import {
mdiInformationOutline,
mdiLabel,
mdiPlus,
mdiTextureBox,
} from "@mdi/js";
import { LitElement, css, html, nothing, type TemplateResult } from "lit";
import {
customElement,
eventOptions,
property,
query,
state,
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import "../../../../components/entity/state-badge";
import "../../../../components/ha-domain-icon";
import "../../../../components/ha-floor-icon";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip";
import type { ConfigEntry } from "../../../../data/config_entries";
import type { HomeAssistant } from "../../../../types";
import type { AddAutomationElementListItem } from "../add-automation-element-dialog";
type Target = [string, string | undefined, string | undefined];
@customElement("ha-automation-add-items")
export class HaAutomationAddItems extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public items?: {
title: string;
items: AddAutomationElementListItem[];
}[];
@property() public error?: string;
@property({ attribute: "select-label" }) public selectLabel!: string;
@property({ attribute: "empty-label" }) public emptyLabel!: string;
@property({ attribute: false }) public target?: Target;
@property({ attribute: false }) public getLabel!: (
id: string
) => { name: string; icon?: string } | undefined;
@property({ attribute: false }) public configEntryLookup: Record<
string,
ConfigEntry
> = {};
@property({ type: Boolean, attribute: "tooltip-description" })
public tooltipDescription = false;
@state() private _itemsScrolled = false;
@query(".items")
private _itemsDiv!: HTMLDivElement;
protected render() {
return html`<div
class=${classMap({
items: true,
blank: this.error || !this.items || !this.items.length,
error: this.error,
scrolled: this._itemsScrolled,
})}
@scroll=${this._onItemsScroll}
>
${!this.items && !this.error
? this.selectLabel
: this.error
? html`${this.error}
<div>${this._renderTarget(this.target)}</div>`
: this.items && !this.items.length
? html`${this.emptyLabel}
${this.target
? html`<div>${this._renderTarget(this.target)}</div>`
: nothing}`
: repeat(
this.items,
(_, index) => `item-group-${index}`,
(itemGroup) =>
this._renderItemList(itemGroup.title, itemGroup.items)
)}
</div>`;
}
private _renderItemList(title, items?: AddAutomationElementListItem[]) {
if (!items || !items.length) {
return nothing;
}
return html`
<div class="items-title">${title}</div>
<ha-md-list>
${repeat(
items,
(item) => item.key,
(item) => html`
<ha-md-list-item
interactive
type="button"
.value=${item.key}
@click=${this._selected}
>
<div slot="headline" class=${this.target ? "item-headline" : ""}>
${item.name}${this._renderTarget(this.target)}
</div>
${!this.tooltipDescription && item.description
? html`<div slot="supporting-text">${item.description}</div>`
: nothing}
${item.icon
? html`<span slot="start">${item.icon}</span>`
: item.iconPath
? html`<ha-svg-icon
slot="start"
.path=${item.iconPath}
></ha-svg-icon>`
: nothing}
${this.tooltipDescription && item.description
? html`<ha-svg-icon
tabindex="0"
id=${`description-tooltip-${item.key}`}
slot="end"
.path=${mdiInformationOutline}
@click=${stopPropagation}
></ha-svg-icon>
<ha-tooltip
.for=${`description-tooltip-${item.key}`}
@wa-show=${stopPropagation}
@wa-hide=${stopPropagation}
@wa-after-hide=${stopPropagation}
@wa-after-show=${stopPropagation}
>${item.description}</ha-tooltip
> `
: nothing}
<ha-svg-icon
slot="end"
class="plus"
.path=${mdiPlus}
></ha-svg-icon>
</ha-md-list-item>
`
)}
</ha-md-list>
`;
}
private _renderTarget = memoizeOne((target?: Target) => {
if (!target) {
return nothing;
}
return html`<div class="selected-target">
${this._getSelectedTargetIcon(target[0], target[1])}
<div class="label">${target[2]}</div>
</div>`;
});
private _getSelectedTargetIcon(
targetType: string,
targetId: string | undefined
): TemplateResult | typeof nothing {
if (!targetId) {
return nothing;
}
if (targetType === "floor") {
return html`<ha-floor-icon
.floor=${this.hass.floors[targetId]}
></ha-floor-icon>`;
}
if (targetType === "area" && this.hass.areas[targetId]) {
const area = this.hass.areas[targetId];
if (area.icon) {
return html`<ha-icon .icon=${area.icon}></ha-icon>`;
}
return html`<ha-svg-icon .path=${mdiTextureBox}></ha-svg-icon>`;
}
if (targetType === "device" && this.hass.devices[targetId]) {
const device = this.hass.devices[targetId];
const configEntry = device.primary_config_entry
? this.configEntryLookup[device.primary_config_entry]
: undefined;
const domain = configEntry?.domain;
if (domain) {
return html`<ha-domain-icon
slot="start"
.hass=${this.hass}
.domain=${domain}
brand-fallback
></ha-domain-icon>`;
}
}
if (targetType === "entity" && this.hass.states[targetId]) {
const stateObj = this.hass.states[targetId];
if (stateObj) {
return html`<state-badge
.stateObj=${stateObj}
.hass=${this.hass}
.stateColor=${false}
></state-badge>`;
}
}
if (targetType === "label") {
const label = this.getLabel(targetId);
if (label?.icon) {
return html`<ha-icon .icon=${label.icon}></ha-icon>`;
}
return html`<ha-svg-icon .path=${mdiLabel}></ha-svg-icon>`;
}
return nothing;
}
private _selected(ev) {
const item = ev.currentTarget;
fireEvent(this, "value-changed", {
value: item.value,
});
}
@eventOptions({ passive: true })
private _onItemsScroll(ev) {
const top = ev.target.scrollTop ?? 0;
this._itemsScrolled = top > 0;
}
public override scrollTo(options?: ScrollToOptions): void;
public override scrollTo(x: number, y: number): void;
public override scrollTo(
xOrOptions?: number | ScrollToOptions,
y?: number
): void {
if (typeof xOrOptions === "number") {
this._itemsDiv?.scrollTo(xOrOptions, y!);
} else {
this._itemsDiv?.scrollTo(xOrOptions);
}
}
static styles = css`
:host {
display: flex;
}
.items {
display: flex;
flex-direction: column;
overflow: auto;
flex: 1;
}
.items.blank {
border-radius: var(--ha-border-radius-xl);
background-color: var(--ha-color-surface-default);
align-items: center;
color: var(--ha-color-text-secondary);
padding: var(--ha-space-0);
margin: var(--ha-space-3) var(--ha-space-4)
max(var(--safe-area-inset-bottom), var(--ha-space-3));
line-height: var(--ha-line-height-expanded);
justify-content: center;
}
.items.error {
background-color: var(--ha-color-fill-danger-quiet-resting);
color: var(--ha-color-on-danger-normal);
}
.items ha-md-list {
--md-list-item-two-line-container-height: var(--ha-space-12);
--md-list-item-leading-space: var(--ha-space-3);
--md-list-item-trailing-space: var(--md-list-item-leading-space);
--md-list-item-bottom-space: var(--ha-space-2);
--md-list-item-top-space: var(--md-list-item-bottom-space);
--md-list-item-supporting-text-font: var(--ha-font-family-body);
--ha-md-list-item-gap: var(--ha-space-3);
gap: var(--ha-space-2);
padding: var(--ha-space-0) var(--ha-space-4);
}
.items ha-md-list ha-md-list-item {
border-radius: var(--ha-border-radius-lg);
border: 1px solid var(--ha-color-border-neutral-quiet);
}
.items ha-md-list {
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-3));
}
.items .item-headline {
display: flex;
align-items: center;
gap: var(--ha-space-1);
min-height: var(--ha-space-9);
flex-wrap: wrap;
}
.items-title {
position: sticky;
display: flex;
align-items: center;
font-weight: var(--ha-font-weight-medium);
padding-top: var(--ha-space-2);
padding-bottom: var(--ha-space-2);
padding-inline-start: var(--ha-space-8);
padding-inline-end: var(--ha-space-3);
top: 0;
z-index: 1;
background-color: var(--card-background-color);
}
ha-bottom-sheet .items-title {
padding-top: var(--ha-space-3);
}
.scrolled .items-title:first-of-type {
box-shadow: var(--bar-box-shadow);
border-bottom: 1px solid var(--ha-color-border-neutral-quiet);
}
ha-icon-next {
width: var(--ha-space-6);
}
ha-svg-icon.plus {
color: var(--primary-color);
}
.selected-target {
display: inline-flex;
gap: var(--ha-space-1);
justify-content: center;
align-items: center;
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);
color: var(--ha-color-on-neutral-normal);
overflow: hidden;
}
.selected-target .label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.selected-target ha-icon,
.selected-target ha-svg-icon,
.selected-target state-badge,
.selected-target ha-domain-icon {
display: flex;
padding: var(--ha-space-1) 0;
}
.selected-target state-badge {
--mdc-icon-size: 20px;
}
.selected-target state-badge,
.selected-target ha-domain-icon {
width: 24px;
height: 24px;
filter: grayscale(100%);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-add-items": HaAutomationAddItems;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,11 +8,13 @@ import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor"; import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import type { Condition } from "../../../../data/automation"; import type { Condition } from "../../../../data/automation";
import { expandConditionWithShorthand } from "../../../../data/automation"; import { expandConditionWithShorthand } from "../../../../data/automation";
import type { ConditionDescription } from "../../../../data/condition";
import { COLLAPSIBLE_CONDITION_ELEMENTS } from "../../../../data/condition"; import { COLLAPSIBLE_CONDITION_ELEMENTS } from "../../../../data/condition";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import "../ha-automation-editor-warning"; import "../ha-automation-editor-warning";
import { editorStyles, indentStyle } from "../styles"; import { editorStyles, indentStyle } from "../styles";
import type { ConditionElement } from "./ha-automation-condition-row"; import type { ConditionElement } from "./ha-automation-condition-row";
import "./types/ha-automation-condition-platform";
@customElement("ha-automation-condition-editor") @customElement("ha-automation-condition-editor")
export default class HaAutomationConditionEditor extends LitElement { export default class HaAutomationConditionEditor extends LitElement {
@@ -35,6 +37,8 @@ export default class HaAutomationConditionEditor extends LitElement {
@property({ type: Boolean, attribute: "supported" }) public uiSupported = @property({ type: Boolean, attribute: "supported" }) public uiSupported =
false; false;
@property({ attribute: false }) public description?: ConditionDescription;
@query("ha-yaml-editor") public yamlEditor?: HaYamlEditor; @query("ha-yaml-editor") public yamlEditor?: HaYamlEditor;
@query(COLLAPSIBLE_CONDITION_ELEMENTS.join(", ")) @query(COLLAPSIBLE_CONDITION_ELEMENTS.join(", "))
@@ -83,16 +87,23 @@ export default class HaAutomationConditionEditor extends LitElement {
` `
: html` : html`
<div @value-changed=${this._onUiChanged}> <div @value-changed=${this._onUiChanged}>
${dynamicElement( ${this.description
`ha-automation-condition-${condition.condition}`, ? html`<ha-automation-condition-platform
{ .hass=${this.hass}
hass: this.hass, .condition=${this.condition}
condition: condition, .description=${this.description}
disabled: this.disabled, .disabled=${this.disabled}
optionsInSidebar: this.indent, ></ha-automation-condition-platform>`
narrow: this.narrow, : dynamicElement(
} `ha-automation-condition-${condition.condition}`,
)} {
hass: this.hass,
condition: condition,
disabled: this.disabled,
optionsInSidebar: this.indent,
narrow: this.narrow,
}
)}
</div> </div>
`} `}
</div> </div>

View File

@@ -32,6 +32,7 @@ import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/ha-automation-row"; import "../../../../components/ha-automation-row";
import type { HaAutomationRow } from "../../../../components/ha-automation-row"; import type { HaAutomationRow } from "../../../../components/ha-automation-row";
import "../../../../components/ha-card"; import "../../../../components/ha-card";
import "../../../../components/ha-condition-icon";
import "../../../../components/ha-expansion-panel"; import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button"; import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-button-menu"; import "../../../../components/ha-md-button-menu";
@@ -44,10 +45,8 @@ import type {
} from "../../../../data/automation"; } from "../../../../data/automation";
import { isCondition, testCondition } from "../../../../data/automation"; import { isCondition, testCondition } from "../../../../data/automation";
import { describeCondition } from "../../../../data/automation_i18n"; import { describeCondition } from "../../../../data/automation_i18n";
import { import type { ConditionDescriptions } from "../../../../data/condition";
CONDITION_BUILDING_BLOCKS, import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
CONDITION_ICONS,
} from "../../../../data/condition";
import { validateConfig } from "../../../../data/config"; import { validateConfig } from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context"; import { fullEntitiesContext } from "../../../../data/context";
import type { EntityRegistryEntry } from "../../../../data/entity_registry"; import type { EntityRegistryEntry } from "../../../../data/entity_registry";
@@ -130,6 +129,9 @@ export default class HaAutomationConditionRow extends LitElement {
@state() private _warnings?: string[]; @state() private _warnings?: string[];
@property({ attribute: false })
public conditionDescriptions: ConditionDescriptions = {};
@property({ type: Boolean, attribute: "sidebar" }) @property({ type: Boolean, attribute: "sidebar" })
public optionsInSidebar = false; public optionsInSidebar = false;
@@ -179,11 +181,11 @@ export default class HaAutomationConditionRow extends LitElement {
private _renderRow() { private _renderRow() {
return html` return html`
<ha-svg-icon <ha-condition-icon
slot="leading-icon" slot="leading-icon"
class="condition-icon" .hass=${this.hass}
.path=${CONDITION_ICONS[this.condition.condition]} .condition=${this.condition.condition}
></ha-svg-icon> ></ha-condition-icon>
<h3 slot="header"> <h3 slot="header">
${capitalizeFirstLetter( ${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg) describeCondition(this.condition, this.hass, this._entityReg)
@@ -395,9 +397,14 @@ export default class HaAutomationConditionRow extends LitElement {
<ha-automation-condition-editor <ha-automation-condition-editor
.hass=${this.hass} .hass=${this.hass}
.condition=${this.condition} .condition=${this.condition}
.description=${this.conditionDescriptions[
this.condition.condition
]}
.disabled=${this.disabled} .disabled=${this.disabled}
.yamlMode=${this._yamlMode} .yamlMode=${this._yamlMode}
.uiSupported=${this._uiSupported(this.condition.condition)} .uiSupported=${this._uiSupported(
this._getType(this.condition, this.conditionDescriptions)
)}
.narrow=${this.narrow} .narrow=${this.narrow}
@ui-mode-not-available=${this._handleUiModeNotAvailable} @ui-mode-not-available=${this._handleUiModeNotAvailable}
></ha-automation-condition-editor>` ></ha-automation-condition-editor>`
@@ -476,7 +483,9 @@ export default class HaAutomationConditionRow extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.condition=${this.condition} .condition=${this.condition}
.disabled=${this.disabled} .disabled=${this.disabled}
.uiSupported=${this._uiSupported(this.condition.condition)} .uiSupported=${this._uiSupported(
this._getType(this.condition, this.conditionDescriptions)
)}
indent indent
.selected=${this._selected} .selected=${this._selected}
.narrow=${this.narrow} .narrow=${this.narrow}
@@ -786,7 +795,10 @@ export default class HaAutomationConditionRow extends LitElement {
cut: this._cutCondition, cut: this._cutCondition,
test: this._testCondition, test: this._testCondition,
config: sidebarCondition, config: sidebarCondition,
uiSupported: this._uiSupported(sidebarCondition.condition), uiSupported: this._uiSupported(
this._getType(sidebarCondition, this.conditionDescriptions)
),
description: this.conditionDescriptions[sidebarCondition.condition],
yamlMode: this._yamlMode, yamlMode: this._yamlMode,
} satisfies ConditionSidebarConfig); } satisfies ConditionSidebarConfig);
this._selected = true; this._selected = true;
@@ -802,6 +814,16 @@ export default class HaAutomationConditionRow extends LitElement {
} }
} }
private _getType = memoizeOne(
(condition: Condition, conditionDescriptions: ConditionDescriptions) => {
if (condition.condition in conditionDescriptions) {
return "platform";
}
return condition.condition;
}
);
private _uiSupported = memoizeOne( private _uiSupported = memoizeOne(
(type: string) => (type: string) =>
customElements.get(`ha-automation-condition-${type}`) !== undefined customElements.get(`ha-automation-condition-${type}`) !== undefined

View File

@@ -4,6 +4,7 @@ import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit"; import { html, LitElement, nothing } from "lit";
import { customElement, property, queryAll, state } from "lit/decorators"; import { customElement, property, queryAll, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat"; import { repeat } from "lit/directives/repeat";
import { ensureArray } from "../../../../common/array/ensure-array";
import { storage } from "../../../../common/decorators/storage"; import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation"; import { stopPropagation } from "../../../../common/dom/stop_propagation";
@@ -12,11 +13,18 @@ import "../../../../components/ha-button";
import "../../../../components/ha-button-menu"; import "../../../../components/ha-button-menu";
import "../../../../components/ha-sortable"; import "../../../../components/ha-sortable";
import "../../../../components/ha-svg-icon"; import "../../../../components/ha-svg-icon";
import type { import {
AutomationClipboard, getValueFromDynamic,
Condition, isDynamic,
type AutomationClipboard,
type Condition,
} from "../../../../data/automation"; } from "../../../../data/automation";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition"; import type { ConditionDescriptions } from "../../../../data/condition";
import {
CONDITION_BUILDING_BLOCKS,
subscribeConditions,
} from "../../../../data/condition";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import { import {
PASTE_VALUE, PASTE_VALUE,
@@ -25,10 +33,9 @@ import {
import { automationRowsStyles } from "../styles"; import { automationRowsStyles } from "../styles";
import "./ha-automation-condition-row"; import "./ha-automation-condition-row";
import type HaAutomationConditionRow from "./ha-automation-condition-row"; import type HaAutomationConditionRow from "./ha-automation-condition-row";
import { ensureArray } from "../../../../common/array/ensure-array";
@customElement("ha-automation-condition") @customElement("ha-automation-condition")
export default class HaAutomationCondition extends LitElement { export default class HaAutomationCondition extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public conditions!: Condition[]; @property({ attribute: false }) public conditions!: Condition[];
@@ -46,6 +53,8 @@ export default class HaAutomationCondition extends LitElement {
@state() private _rowSortSelected?: number; @state() private _rowSortSelected?: number;
@state() private _conditionDescriptions: ConditionDescriptions = {};
@state() @state()
@storage({ @storage({
key: "automationClipboard", key: "automationClipboard",
@@ -64,6 +73,26 @@ export default class HaAutomationCondition extends LitElement {
private _conditionKeys = new WeakMap<Condition, string>(); private _conditionKeys = new WeakMap<Condition, string>();
protected hassSubscribe() {
return [
subscribeConditions(this.hass, (conditions) =>
this._addConditions(conditions)
),
];
}
private _addConditions(conditions: ConditionDescriptions) {
this._conditionDescriptions = {
...this._conditionDescriptions,
...conditions,
};
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("conditions");
}
protected updated(changedProperties: PropertyValues) { protected updated(changedProperties: PropertyValues) {
if (!changedProperties.has("conditions")) { if (!changedProperties.has("conditions")) {
return; return;
@@ -168,6 +197,7 @@ export default class HaAutomationCondition extends LitElement {
.last=${idx === this.conditions.length - 1} .last=${idx === this.conditions.length - 1}
.totalConditions=${this.conditions.length} .totalConditions=${this.conditions.length}
.condition=${cond} .condition=${cond}
.conditionDescriptions=${this._conditionDescriptions}
.disabled=${this.disabled} .disabled=${this.disabled}
.narrow=${this.narrow} .narrow=${this.narrow}
@duplicate=${this._duplicateCondition} @duplicate=${this._duplicateCondition}
@@ -237,6 +267,10 @@ export default class HaAutomationCondition extends LitElement {
conditions = this.conditions.concat( conditions = this.conditions.concat(
deepClone(this._clipboard!.condition) deepClone(this._clipboard!.condition)
); );
} else if (isDynamic(value)) {
conditions = this.conditions.concat({
condition: getValueFromDynamic(value),
});
} else { } else {
const condition = value as Condition["condition"]; const condition = value as Condition["condition"];
const elClass = customElements.get( const elClass = customElements.get(

View File

@@ -0,0 +1,416 @@
import { mdiHelpCircle } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDomain } from "../../../../../common/entity/compute_domain";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
import type { PlatformCondition } from "../../../../../data/automation";
import {
getConditionDomain,
getConditionObjectId,
type ConditionDescription,
} from "../../../../../data/condition";
import type { IntegrationManifest } from "../../../../../data/integration";
import { fetchIntegrationManifest } from "../../../../../data/integration";
import type { TargetSelector } from "../../../../../data/selector";
import type { HomeAssistant } from "../../../../../types";
import { documentationUrl } from "../../../../../util/documentation-url";
const showOptionalToggle = (field: ConditionDescription["fields"][string]) =>
field.selector &&
!field.required &&
!("boolean" in field.selector && field.default);
@customElement("ha-automation-condition-platform")
export class HaPlatformCondition extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public condition!: PlatformCondition;
@property({ attribute: false }) public description?: ConditionDescription;
@property({ type: Boolean }) public disabled = false;
@state() private _checkedKeys = new Set();
@state() private _manifest?: IntegrationManifest;
public static get defaultConfig(): PlatformCondition {
return { condition: "" };
}
protected willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
if (!this.hasUpdated) {
this.hass.loadBackendTranslation("conditions");
this.hass.loadBackendTranslation("selector");
}
if (!changedProperties.has("condition")) {
return;
}
const oldValue = changedProperties.get("condition") as
| undefined
| this["condition"];
// Fetch the manifest if we have a condition selected and the condition domain changed.
// If no condition is selected, clear the manifest.
if (this.condition?.condition) {
const domain = getConditionDomain(this.condition.condition);
const oldDomain = getConditionDomain(oldValue?.condition || "");
if (domain !== oldDomain) {
this._fetchManifest(domain);
}
} else {
this._manifest = undefined;
}
}
protected render() {
const domain = getConditionDomain(this.condition.condition);
const conditionName = getConditionObjectId(this.condition.condition);
const description = this.hass.localize(
`component.${domain}.conditions.${conditionName}.description`
);
const conditionDesc = this.description;
const shouldRenderDataYaml = !conditionDesc?.fields;
const hasOptional = Boolean(
conditionDesc?.fields &&
Object.values(conditionDesc.fields).some((field) =>
showOptionalToggle(field)
)
);
return html`
<div class="description">
${description ? html`<p>${description}</p>` : nothing}
${this._manifest
? html`<a
href=${this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
)
: this._manifest.documentation}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
class="help-icon"
></ha-icon-button>
</a>`
: nothing}
</div>
${conditionDesc && "target" in conditionDesc
? html`<ha-settings-row narrow>
${hasOptional
? html`<div slot="prefix" class="checkbox-spacer"></div>`
: nothing}
<span slot="heading"
>${this.hass.localize(
"ui.components.service-control.target"
)}</span
>
<span slot="description"
>${this.hass.localize(
"ui.components.service-control.target_secondary"
)}</span
><ha-selector
.hass=${this.hass}
.selector=${this._targetSelector(conditionDesc.target)}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this.condition?.target}
></ha-selector
></ha-settings-row>`
: nothing}
${shouldRenderDataYaml
? html`<ha-yaml-editor
.hass=${this.hass}
.label=${this.hass.localize(
"ui.components.service-control.action_data"
)}
.name=${"data"}
.readOnly=${this.disabled}
.defaultValue=${this.condition?.options}
@value-changed=${this._dataChanged}
></ha-yaml-editor>`
: Object.entries(conditionDesc.fields).map(([fieldName, dataField]) =>
this._renderField(
fieldName,
dataField,
hasOptional,
domain,
conditionName
)
)}
`;
}
private _targetSelector = memoizeOne(
(targetSelector: TargetSelector["target"] | null | undefined) =>
targetSelector ? { target: { ...targetSelector } } : { target: {} }
);
private _renderField = (
fieldName: string,
dataField: ConditionDescription["fields"][string],
hasOptional: boolean,
domain: string | undefined,
conditionName: string | undefined
) => {
const selector = dataField?.selector ?? { text: null };
const showOptional = showOptionalToggle(dataField);
return dataField.selector
? html`<ha-settings-row narrow>
${!showOptional
? hasOptional
? html`<div slot="prefix" class="checkbox-spacer"></div>`
: nothing
: html`<ha-checkbox
.key=${fieldName}
.checked=${this._checkedKeys.has(fieldName) ||
(this.condition?.options &&
this.condition.options[fieldName] !== undefined)}
.disabled=${this.disabled}
@change=${this._checkboxChanged}
slot="prefix"
></ha-checkbox>`}
<span slot="heading"
>${this.hass.localize(
`component.${domain}.conditions.${conditionName}.fields.${fieldName}.name`
) || conditionName}</span
>
<span slot="description"
>${this.hass.localize(
`component.${domain}.conditions.${conditionName}.fields.${fieldName}.description`
)}</span
>
<ha-selector
.disabled=${this.disabled ||
(showOptional &&
!this._checkedKeys.has(fieldName) &&
(!this.condition?.options ||
this.condition.options[fieldName] === undefined))}
.hass=${this.hass}
.selector=${selector}
.context=${this._generateContext(dataField)}
.key=${fieldName}
@value-changed=${this._dataChanged}
.value=${this.condition?.options
? this.condition.options[fieldName]
: undefined}
.placeholder=${dataField.default}
.localizeValue=${this._localizeValueCallback}
></ha-selector>
</ha-settings-row>`
: nothing;
};
private _generateContext(
field: ConditionDescription["fields"][string]
): Record<string, any> | undefined {
if (!field.context) {
return undefined;
}
const context = {};
for (const [context_key, data_key] of Object.entries(field.context)) {
context[context_key] =
data_key === "target"
? this.condition.target
: this.condition.options?.[data_key];
}
return context;
}
private _dataChanged(ev: CustomEvent) {
ev.stopPropagation();
if (ev.detail.isValid === false) {
// Don't clear an object selector that returns invalid YAML
return;
}
const key = (ev.currentTarget as any).key;
const value = ev.detail.value;
if (
this.condition?.options?.[key] === value ||
((!this.condition?.options || !(key in this.condition.options)) &&
(value === "" || value === undefined))
) {
return;
}
const options = { ...this.condition?.options, [key]: value };
if (
value === "" ||
value === undefined ||
(typeof value === "object" && !Object.keys(value).length)
) {
delete options[key];
}
fireEvent(this, "value-changed", {
value: {
...this.condition,
options,
},
});
}
private _targetChanged(ev: CustomEvent): void {
ev.stopPropagation();
fireEvent(this, "value-changed", {
value: {
...this.condition,
target: ev.detail.value,
},
});
}
private _checkboxChanged(ev) {
const checked = ev.currentTarget.checked;
const key = ev.currentTarget.key;
let options;
if (checked) {
this._checkedKeys.add(key);
const field =
this.description &&
Object.entries(this.description).find(([k, _value]) => k === key)?.[1];
let defaultValue = field?.default;
if (
defaultValue == null &&
field?.selector &&
"constant" in field.selector
) {
defaultValue = field.selector.constant?.value;
}
if (
defaultValue == null &&
field?.selector &&
"boolean" in field.selector
) {
defaultValue = false;
}
if (defaultValue != null) {
options = {
...this.condition?.options,
[key]: defaultValue,
};
}
} else {
this._checkedKeys.delete(key);
options = { ...this.condition?.options };
delete options[key];
}
if (options) {
fireEvent(this, "value-changed", {
value: {
...this.condition,
options,
},
});
}
this.requestUpdate("_checkedKeys");
}
private _localizeValueCallback = (key: string) => {
if (!this.condition?.condition) {
return "";
}
return this.hass.localize(
`component.${computeDomain(this.condition.condition)}.selector.${key}`
);
};
private async _fetchManifest(integration: string) {
this._manifest = undefined;
try {
this._manifest = await fetchIntegrationManifest(this.hass, integration);
} catch (_err: any) {
// eslint-disable-next-line no-console
console.log(`Unable to fetch integration manifest for ${integration}`);
// Ignore if loading manifest fails. Probably bad JSON in manifest
}
}
static styles = css`
ha-settings-row {
padding: 0 var(--ha-space-4);
}
ha-settings-row[narrow] {
padding-bottom: var(--ha-space-2);
}
ha-settings-row {
--settings-row-content-width: 100%;
--settings-row-prefix-display: contents;
border-top: var(
--service-control-items-border-top,
1px solid var(--divider-color)
);
}
ha-service-picker,
ha-entity-picker,
ha-yaml-editor {
display: block;
margin: 0 var(--ha-space-4);
}
ha-yaml-editor {
padding: var(--ha-space-4) 0;
}
p {
margin: 0 var(--ha-space-4);
padding: var(--ha-space-4) 0;
}
:host([hide-picker]) p {
padding-top: 0;
}
.checkbox-spacer {
width: 32px;
}
ha-checkbox {
margin-left: calc(var(--ha-space-4) * -1);
margin-inline-start: calc(var(--ha-space-4) * -1);
margin-inline-end: initial;
}
.help-icon {
color: var(--secondary-text-color);
}
.description {
justify-content: space-between;
display: flex;
align-items: center;
padding-right: 2px;
padding-inline-end: 2px;
padding-inline-start: initial;
}
.description p {
direction: ltr;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-condition-platform": HaPlatformCondition;
}
}

View File

@@ -299,7 +299,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
}, },
area: { area: {
title: localize("ui.panel.config.automation.picker.headers.area"), title: localize("ui.panel.config.automation.picker.headers.area"),
defaultHidden: true,
groupable: true, groupable: true,
filterable: true, filterable: true,
sortable: true, sortable: true,

View File

@@ -188,6 +188,7 @@ export default class HaAutomationSidebar extends LitElement {
class="handle ${this._resizing ? "resizing" : ""}" class="handle ${this._resizing ? "resizing" : ""}"
@mousedown=${this._handleMouseDown} @mousedown=${this._handleMouseDown}
@touchstart=${this._handleMouseDown} @touchstart=${this._handleMouseDown}
@dblclick=${this._handleDoubleClick}
@focus=${this._startKeyboardResizing} @focus=${this._startKeyboardResizing}
@blur=${this._stopKeyboardResizing} @blur=${this._stopKeyboardResizing}
tabindex="0" tabindex="0"
@@ -258,6 +259,17 @@ export default class HaAutomationSidebar extends LitElement {
); );
}; };
private _handleDoubleClick = (ev: MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
this._unregisterResizeHandlers();
this._tinykeysUnsub?.();
this._tinykeysUnsub = undefined;
this._resizing = false;
document.body.style.removeProperty("cursor");
fireEvent(this, "sidebar-reset-size");
};
private _startResizing(clientX: number) { private _startResizing(clientX: number) {
// register event listeners for drag handling // register event listeners for drag handling
document.addEventListener("mousemove", this._handleMouseMove); document.addEventListener("mousemove", this._handleMouseMove);
@@ -422,5 +434,6 @@ declare global {
deltaInPx: number; deltaInPx: number;
}; };
"sidebar-resizing-stopped": undefined; "sidebar-resizing-stopped": undefined;
"sidebar-reset-size": undefined;
} }
} }

View File

@@ -317,6 +317,7 @@ export class HaManualAutomationEditor extends LitElement {
@value-changed=${this._sidebarConfigChanged} @value-changed=${this._sidebarConfigChanged}
@sidebar-resized=${this._resizeSidebar} @sidebar-resized=${this._resizeSidebar}
@sidebar-resizing-stopped=${this._stopResizeSidebar} @sidebar-resizing-stopped=${this._stopResizeSidebar}
@sidebar-reset-size=${this._resetSidebarWidth}
></ha-automation-sidebar> ></ha-automation-sidebar>
</div> </div>
</div> </div>
@@ -700,6 +701,16 @@ export class HaManualAutomationEditor extends LitElement {
this._prevSidebarWidthPx = undefined; this._prevSidebarWidthPx = undefined;
} }
private _resetSidebarWidth(ev: Event) {
ev.stopPropagation();
this._prevSidebarWidthPx = undefined;
this._sidebarWidthPx = SIDEBAR_DEFAULT_WIDTH;
this.style.setProperty(
"--sidebar-dynamic-width",
`${this._sidebarWidthPx}px`
);
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
saveFabStyles, saveFabStyles,

View File

@@ -1,14 +1,6 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { mdiClose, mdiDotsVertical } from "@mdi/js"; import { mdiClose, mdiDotsVertical } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { import { customElement, property, query } from "lit/decorators";
customElement,
eventOptions,
property,
query,
state,
} from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation"; import { stopPropagation } from "../../../../common/dom/stop_propagation";
@@ -17,8 +9,10 @@ import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button"; import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-button-menu"; import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-divider"; import "../../../../components/ha-md-divider";
import { haStyleScrollbar } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import "../ha-automation-editor-warning"; import "../ha-automation-editor-warning";
import { ScrollableFadeMixin } from "../../../../mixins/scrollable-fade-mixin";
export interface SidebarOverflowMenuEntry { export interface SidebarOverflowMenuEntry {
clickAction: () => void; clickAction: () => void;
@@ -31,7 +25,9 @@ export interface SidebarOverflowMenuEntry {
export type SidebarOverflowMenu = (SidebarOverflowMenuEntry | "separator")[]; export type SidebarOverflowMenu = (SidebarOverflowMenuEntry | "separator")[];
@customElement("ha-automation-sidebar-card") @customElement("ha-automation-sidebar-card")
export default class HaAutomationSidebarCard extends LitElement { export default class HaAutomationSidebarCard extends ScrollableFadeMixin(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, attribute: "wide" }) public isWide = false; @property({ type: Boolean, attribute: "wide" }) public isWide = false;
@@ -42,23 +38,10 @@ export default class HaAutomationSidebarCard extends LitElement {
@property({ type: Boolean }) public narrow = false; @property({ type: Boolean }) public narrow = false;
@state() private _contentScrolled = false;
@state() private _contentScrollable = false;
@query(".card-content") private _contentElement!: HTMLDivElement; @query(".card-content") private _contentElement!: HTMLDivElement;
private _contentSize = new ResizeController(this, { protected get scrollableElement(): HTMLElement | null {
target: null, return this._contentElement;
callback: (entries) => {
if (entries[0]?.target) {
this._canScrollDown(entries[0].target);
}
},
});
protected firstUpdated(_changedProperties: PropertyValues): void {
this._contentSize.observe(this._contentElement);
} }
protected render() { protected render() {
@@ -70,9 +53,7 @@ export default class HaAutomationSidebarCard extends LitElement {
yaml: this.yamlMode, yaml: this.yamlMode,
})} })}
> >
<ha-dialog-header <ha-dialog-header>
class=${classMap({ scrolled: this._contentScrolled })}
>
<ha-icon-button <ha-icon-button
slot="navigationIcon" slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")} .label=${this.hass.localize("ui.common.close")}
@@ -107,34 +88,14 @@ export default class HaAutomationSidebarCard extends LitElement {
> >
</ha-automation-editor-warning>` </ha-automation-editor-warning>`
: nothing} : nothing}
<div class="card-content" @scroll=${this._onScroll}> <div class="card-content ha-scrollbar">
<slot></slot> <slot></slot>
${this.renderScrollableFades(this.isWide)}
</div> </div>
<div
class=${classMap({ fade: true, scrollable: this._contentScrollable })}
></div>
</ha-card> </ha-card>
`; `;
} }
@eventOptions({ passive: true })
private _onScroll(ev) {
const top = ev.target.scrollTop ?? 0;
this._contentScrolled = top > 0;
this._canScrollDown(ev.target);
}
private _canScrollDown(element: HTMLElement) {
const safeAreaInsetBottom =
parseFloat(
getComputedStyle(element).getPropertyValue("--safe-area-inset-bottom")
) || 0;
this._contentScrollable =
(element.scrollHeight ?? 0) - (element.clientHeight ?? 0) >
(element.scrollTop ?? 0) + safeAreaInsetBottom + 16;
}
private _closeSidebar() { private _closeSidebar() {
fireEvent(this, "close-sidebar"); fireEvent(this, "close-sidebar");
} }
@@ -144,86 +105,63 @@ export default class HaAutomationSidebarCard extends LitElement {
ev.preventDefault(); ev.preventDefault();
} }
static styles = css` static get styles() {
ha-card { return [
position: relative; ...super.styles,
height: 100%; haStyleScrollbar,
width: 100%; css`
border-color: var(--primary-color); ha-card {
border-width: 2px; position: relative;
display: flex; height: 100%;
flex-direction: column; width: 100%;
} border-color: var(--primary-color);
border-width: 2px;
display: flex;
flex-direction: column;
}
@media all and (max-width: 870px) { @media all and (max-width: 870px) {
ha-card.mobile { ha-card.mobile {
border: none; border: none;
box-shadow: none; box-shadow: none;
} }
ha-card.mobile { ha-card.mobile {
border-bottom-right-radius: var(--ha-border-radius-square); border-bottom-right-radius: var(--ha-border-radius-square);
border-bottom-left-radius: var(--ha-border-radius-square); border-bottom-left-radius: var(--ha-border-radius-square);
} }
} }
ha-dialog-header { ha-dialog-header {
border-radius: var(--ha-card-border-radius); border-radius: var(--ha-card-border-radius);
box-shadow: none; border-bottom-left-radius: 0;
transition: box-shadow 180ms ease-in-out; border-bottom-right-radius: 0;
border-bottom-left-radius: 0; position: relative;
border-bottom-right-radius: 0; background-color: var(
position: relative; --ha-dialog-surface-background,
background-color: var( var(--mdc-theme-surface, #fff)
--ha-dialog-surface-background, );
var(--mdc-theme-surface, #fff) }
);
}
ha-dialog-header.scrolled { .card-content {
box-shadow: var(--bar-box-shadow); flex: 1 1 auto;
} min-height: 0;
overflow: auto;
margin-top: 0;
padding-bottom: max(var(--safe-area-inset-bottom, 0px), 32px);
}
.fade { .fade-top {
position: absolute; top: var(--ha-space-17);
bottom: 1px; }
left: 1px;
right: 1px;
height: 16px;
pointer-events: none;
transition: box-shadow 180ms ease-in-out;
background-color: var(
--ha-dialog-surface-background,
var(--mdc-theme-surface, #fff)
);
transform: rotate(180deg);
border-radius: var(--ha-card-border-radius);
border-bottom-left-radius: var(--ha-border-radius-square);
border-bottom-right-radius: var(--ha-border-radius-square);
}
.fade.scrollable { @media all and (max-width: 870px) {
box-shadow: var(--bar-box-shadow); .card-content {
} padding-bottom: 42px;
}
.card-content { }
flex: 1 1 auto; `,
min-height: 0; ];
overflow: auto; }
margin-top: 0;
padding-bottom: max(var(--safe-area-inset-bottom, 0px), 32px);
}
@media all and (max-width: 870px) {
.fade {
bottom: 0;
border-radius: var(--ha-border-radius-square);
}
.card-content {
padding-bottom: 42px;
}
}
`;
} }
declare global { declare global {

View File

@@ -16,11 +16,16 @@ import { classMap } from "lit/directives/class-map";
import { keyed } from "lit/directives/keyed"; import { keyed } from "lit/directives/keyed";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { handleStructError } from "../../../../common/structs/handle-errors"; import { handleStructError } from "../../../../common/structs/handle-errors";
import { import type {
testCondition, LegacyCondition,
type ConditionSidebarConfig, ConditionSidebarConfig,
} from "../../../../data/automation"; } from "../../../../data/automation";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition"; import { testCondition } from "../../../../data/automation";
import {
CONDITION_BUILDING_BLOCKS,
getConditionDomain,
getConditionObjectId,
} from "../../../../data/condition";
import { validateConfig } from "../../../../data/config"; import { validateConfig } from "../../../../data/config";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac"; import { isMac } from "../../../../util/is_mac";
@@ -84,14 +89,25 @@ export default class HaAutomationSidebarCondition extends LitElement {
"ui.panel.config.automation.editor.conditions.condition" "ui.panel.config.automation.editor.conditions.condition"
); );
const domain =
"condition" in this.config.config &&
getConditionDomain(this.config.config.condition);
const conditionName =
"condition" in this.config.config &&
getConditionObjectId(this.config.config.condition);
const title = const title =
this.hass.localize( this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.${type}.label` `ui.panel.config.automation.editor.conditions.type.${type as LegacyCondition["condition"]}.label`
) || type; ) ||
this.hass.localize(
`component.${domain}.conditions.${conditionName}.name`
) ||
type;
const description = isBuildingBlock const description = isBuildingBlock
? this.hass.localize( ? this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.${type}.description.picker` `ui.panel.config.automation.editor.conditions.type.${type as LegacyCondition["condition"]}.description.picker`
) )
: ""; : "";
@@ -282,6 +298,7 @@ export default class HaAutomationSidebarCondition extends LitElement {
class="sidebar-editor" class="sidebar-editor"
.hass=${this.hass} .hass=${this.hass}
.condition=${this.config.config} .condition=${this.config.config}
.description=${this.config.description}
.yamlMode=${this.yamlMode} .yamlMode=${this.yamlMode}
.uiSupported=${this.config.uiSupported} .uiSupported=${this.config.uiSupported}
@value-changed=${this._valueChangedSidebar} @value-changed=${this._valueChangedSidebar}

View File

@@ -1,14 +1,10 @@
import { mdiOpenInNew } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import "../../../components/ha-analytics"; import "../../../components/ha-analytics";
import "../../../components/ha-button";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-checkbox";
import "../../../components/ha-settings-row"; import "../../../components/ha-settings-row";
import "../../../components/ha-svg-icon";
import type { Analytics } from "../../../data/analytics"; import type { Analytics } from "../../../data/analytics";
import { import {
getAnalyticsDetails, getAnalyticsDetails,
@@ -17,6 +13,8 @@ import {
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url"; import { documentationUrl } from "../../../util/documentation-url";
import type { HaSwitch } from "../../../components/ha-switch";
import "../../../components/ha-alert";
@customElement("ha-config-analytics") @customElement("ha-config-analytics")
class ConfigAnalytics extends LitElement { class ConfigAnalytics extends LitElement {
@@ -34,10 +32,22 @@ class ConfigAnalytics extends LitElement {
: undefined; : undefined;
return html` return html`
<ha-card outlined> <ha-card
outlined
.header=${this.hass.localize("ui.panel.config.analytics.header") ||
"Home Assistant analytics"}
>
<div class="card-content"> <div class="card-content">
${error ? html`<div class="error">${error}</div>` : ""} ${error ? html`<div class="error">${error}</div>` : nothing}
<p>${this.hass.localize("ui.panel.config.analytics.intro")}</p> <p>
${this.hass.localize("ui.panel.config.analytics.intro")}
<a
href=${documentationUrl(this.hass, "/integrations/analytics/")}
target="_blank"
rel="noreferrer"
>${this.hass.localize("ui.panel.config.analytics.learn_more")}</a
>.
</p>
<ha-analytics <ha-analytics
translation_key_panel="config" translation_key_panel="config"
@analytics-preferences-changed=${this._preferencesChanged} @analytics-preferences-changed=${this._preferencesChanged}
@@ -45,26 +55,59 @@ class ConfigAnalytics extends LitElement {
.analytics=${this._analyticsDetails} .analytics=${this._analyticsDetails}
></ha-analytics> ></ha-analytics>
</div> </div>
<div class="card-actions">
<ha-button @click=${this._save}>
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.save_button"
)}
</ha-button>
</div>
</ha-card> </ha-card>
<div class="footer"> ${this._analyticsDetails &&
<ha-button "snapshots" in this._analyticsDetails.preferences
size="small" ? html`<ha-card
appearance="plain" outlined
href=${documentationUrl(this.hass, "/integrations/analytics/")} .header=${this.hass.localize(
target="_blank" "ui.panel.config.analytics.preferences.snapshots.header"
rel="noreferrer" )}
> >
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon> <div class="card-content">
${this.hass.localize("ui.panel.config.analytics.learn_more")} <p>
</ha-button> ${this.hass.localize(
</div> "ui.panel.config.analytics.preferences.snapshots.info"
)}
<a
href=${documentationUrl(this.hass, "/device-database/")}
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.analytics.preferences.snapshots.learn_more"
)}</a
>.
</p>
<ha-alert
.title=${this.hass.localize(
"ui.panel.config.analytics.preferences.snapshots.alert.title"
)}
>${this.hass.localize(
"ui.panel.config.analytics.preferences.snapshots.alert.content"
)}</ha-alert
>
<ha-settings-row>
<span slot="heading" data-for="snapshots">
${this.hass.localize(
`ui.panel.config.analytics.preferences.snapshots.title`
)}
</span>
<span slot="description" data-for="snapshots">
${this.hass.localize(
`ui.panel.config.analytics.preferences.snapshots.description`
)}
</span>
<ha-switch
@change=${this._handleDeviceRowClick}
.checked=${!!this._analyticsDetails?.preferences.snapshots}
.disabled=${this._analyticsDetails === undefined}
name="snapshots"
>
</ha-switch>
</ha-settings-row>
</div>
</ha-card>`
: nothing}
`; `;
} }
@@ -96,11 +139,25 @@ class ConfigAnalytics extends LitElement {
} }
} }
private _handleDeviceRowClick(ev: Event) {
const target = ev.target as HaSwitch;
this._analyticsDetails = {
...this._analyticsDetails!,
preferences: {
...this._analyticsDetails!.preferences,
snapshots: target.checked,
},
};
this._save();
}
private _preferencesChanged(event: CustomEvent): void { private _preferencesChanged(event: CustomEvent): void {
this._analyticsDetails = { this._analyticsDetails = {
...this._analyticsDetails!, ...this._analyticsDetails!,
preferences: event.detail.preferences, preferences: event.detail.preferences,
}; };
this._save();
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
@@ -117,21 +174,10 @@ class ConfigAnalytics extends LitElement {
p { p {
margin-top: 0; margin-top: 0;
} }
.card-actions { ha-card:not(:first-of-type) {
display: flex; margin-top: 24px;
flex-direction: row-reverse;
justify-content: space-between;
align-items: center;
} }
.footer { `,
padding: 32px 0 16px;
text-align: center;
}
ha-button[size="small"] ha-svg-icon {
--mdc-icon-size: 16px;
}
`, // row-reverse so we tab first to "save"
]; ];
} }
} }

View File

@@ -2,9 +2,7 @@ import { mdiDotsVertical, mdiDownload } from "@mdi/js";
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import "../../../components/ha-button-menu";
import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button";
import "../../../components/ha-list-item";
import "../../../components/ha-svg-icon"; import "../../../components/ha-svg-icon";
import { getSignedPath } from "../../../data/auth"; import { getSignedPath } from "../../../data/auth";
import "../../../layouts/hass-subpage"; import "../../../layouts/hass-subpage";
@@ -14,6 +12,8 @@ import {
downloadFileSupported, downloadFileSupported,
fileDownload, fileDownload,
} from "../../../util/file_download"; } from "../../../util/file_download";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-dropdown";
@customElement("ha-config-section-analytics") @customElement("ha-config-section-analytics")
class HaConfigSectionAnalytics extends LitElement { class HaConfigSectionAnalytics extends LitElement {
@@ -33,22 +33,19 @@ class HaConfigSectionAnalytics extends LitElement {
> >
${downloadFileSupported(this.hass) ${downloadFileSupported(this.hass)
? html` ? html`
<ha-button-menu <ha-dropdown
@action=${this._handleOverflowAction} @wa-select=${this._handleOverflowAction}
slot="toolbar-icon" slot="toolbar-icon"
> >
<ha-icon-button slot="trigger" .path=${mdiDotsVertical}> <ha-icon-button slot="trigger" .path=${mdiDotsVertical}>
</ha-icon-button> </ha-icon-button>
<ha-list-item graphic="icon"> <ha-dropdown-item .value=${"download_device_info"}>
<ha-svg-icon <ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
slot="graphic"
.path=${mdiDownload}
></ha-svg-icon>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.analytics.download_device_info" "ui.panel.config.analytics.download_device_info"
)} )}
</ha-list-item> </ha-dropdown-item>
</ha-button-menu> </ha-dropdown>
` `
: nothing} : nothing}
<div class="content"> <div class="content">
@@ -58,9 +55,16 @@ class HaConfigSectionAnalytics extends LitElement {
`; `;
} }
private async _handleOverflowAction(): Promise<void> { private async _handleOverflowAction(
const signedPath = await getSignedPath(this.hass, "/api/analytics/devices"); ev: CustomEvent<{ item: { value: string } }>
fileDownload(signedPath.path); ): Promise<void> {
if (ev.detail.item.value === "download_device_info") {
const signedPath = await getSignedPath(
this.hass,
"/api/analytics/devices"
);
fileDownload(signedPath.path);
}
} }
static styles = css` static styles = css`

View File

@@ -1,4 +1,3 @@
import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item";
import { mdiDotsVertical, mdiRefresh } from "@mdi/js"; import { mdiDotsVertical, mdiRefresh } from "@mdi/js";
import type { HassEntities } from "home-assistant-js-websocket"; import type { HassEntities } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
@@ -6,13 +5,9 @@ import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
import "../../../components/ha-alert"; import "../../../components/ha-alert";
import "../../../components/ha-bar"; import "../../../components/ha-bar";
import "../../../components/ha-button-menu";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-check-list-item";
import "../../../components/ha-list-item";
import "../../../components/ha-metric"; import "../../../components/ha-metric";
import { extractApiErrorMessage } from "../../../data/hassio/common"; import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { import type {
@@ -33,6 +28,9 @@ import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import "../dashboard/ha-config-updates"; import "../dashboard/ha-config-updates";
import { showJoinBetaDialog } from "./updates/show-dialog-join-beta"; 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") @customElement("ha-config-section-updates")
class HaConfigSectionUpdates extends LitElement { class HaConfigSectionUpdates extends LitElement {
@@ -73,24 +71,25 @@ class HaConfigSectionUpdates extends LitElement {
.path=${mdiRefresh} .path=${mdiRefresh}
@click=${this._checkUpdates} @click=${this._checkUpdates}
></ha-icon-button> ></ha-icon-button>
<ha-button-menu multi> <ha-dropdown @wa-select=${this._handleOverflowAction}>
<ha-icon-button <ha-icon-button
slot="trigger" slot="trigger"
.label=${this.hass.localize("ui.common.menu")} .label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical} .path=${mdiDotsVertical}
></ha-icon-button> ></ha-icon-button>
<ha-check-list-item
left <ha-dropdown-item
@request-selected=${this._toggleSkipped} type="checkbox"
.selected=${this._showSkipped} value="show_skipped"
.checked=${this._showSkipped}
> >
${this.hass.localize("ui.panel.config.updates.show_skipped")} ${this.hass.localize("ui.panel.config.updates.show_skipped")}
</ha-check-list-item> </ha-dropdown-item>
${this._supervisorInfo ${this._supervisorInfo
? html` ? html`
<li divider role="separator"></li> <wa-divider></wa-divider>
<ha-list-item <ha-dropdown-item
@request-selected=${this._toggleBeta} value="toggle_beta"
.disabled=${this._supervisorInfo.channel === "dev"} .disabled=${this._supervisorInfo.channel === "dev"}
> >
${this._supervisorInfo.channel === "stable" ${this._supervisorInfo.channel === "stable"
@@ -98,10 +97,10 @@ class HaConfigSectionUpdates extends LitElement {
: this.hass.localize( : this.hass.localize(
"ui.panel.config.updates.leave_beta" "ui.panel.config.updates.leave_beta"
)} )}
</ha-list-item> </ha-dropdown-item>
` `
: ""} : ""}
</ha-button-menu> </ha-dropdown>
</div> </div>
<div class="content"> <div class="content">
<ha-card outlined> <ha-card outlined>
@@ -133,27 +132,19 @@ class HaConfigSectionUpdates extends LitElement {
this._supervisorInfo = await fetchHassioSupervisorInfo(this.hass); this._supervisorInfo = await fetchHassioSupervisorInfo(this.hass);
} }
private _toggleSkipped(ev: CustomEvent<RequestSelectedDetail>): void { private async _handleOverflowAction(
if (ev.detail.source !== "property") { ev: CustomEvent<{ item: { value: string } }>
return;
}
this._showSkipped = !this._showSkipped;
}
private async _toggleBeta(
ev: CustomEvent<RequestSelectedDetail>
): Promise<void> { ): Promise<void> {
if (!shouldHandleRequestSelectedEvent(ev)) { if (ev.detail.item.value === "toggle_beta") {
return; if (this._supervisorInfo!.channel === "stable") {
} showJoinBetaDialog(this, {
join: async () => this._setChannel("beta"),
if (this._supervisorInfo!.channel === "stable") { });
showJoinBetaDialog(this, { } else {
join: async () => this._setChannel("beta"), this._setChannel("stable");
}); }
} else { } else if (ev.detail.item.value === "show_skipped") {
this._setChannel("stable"); this._showSkipped = !this._showSkipped;
} }
} }

View File

@@ -1,7 +1,9 @@
import type { CSSResultGroup, TemplateResult } from "lit"; import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name"; import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
import { stringCompare } from "../../../../common/string/compare";
import { titleCase } from "../../../../common/string/title-case"; import { titleCase } from "../../../../common/string/title-case";
import "../../../../components/ha-card"; import "../../../../components/ha-card";
import type { DeviceRegistryEntry } from "../../../../data/device_registry"; import type { DeviceRegistryEntry } from "../../../../data/device_registry";
@@ -9,16 +11,61 @@ import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import { createSearchParam } from "../../../../common/url/search-params"; import { createSearchParam } from "../../../../common/url/search-params";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import "../../../../components/ha-icon";
import "../../../../components/ha-label";
import type { LabelRegistryEntry } from "../../../../data/label_registry";
import { subscribeLabelRegistry } from "../../../../data/label_registry";
import { computeCssColor } from "../../../../common/color/compute-color";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
@customElement("ha-device-info-card") @customElement("ha-device-info-card")
export class HaDeviceCard extends LitElement { export class HaDeviceCard extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public device!: DeviceRegistryEntry; @property({ attribute: false }) public device!: DeviceRegistryEntry;
@property({ type: Boolean }) public narrow = false; @property({ type: Boolean }) public narrow = false;
@state() private _labelRegistry?: LabelRegistryEntry[];
private _labelsData = memoizeOne(
(
labels: LabelRegistryEntry[] | undefined,
labelIds: string[],
language: string
): {
map: Map<string, LabelRegistryEntry>;
ids: string[];
} => {
const map = labels
? new Map(labels.map((label) => [label.label_id, label]))
: new Map<string, LabelRegistryEntry>();
const ids = [...labelIds].sort((labelA, labelB) =>
stringCompare(
map.get(labelA)?.name || labelA,
map.get(labelB)?.name || labelB,
language
)
);
return { map, ids };
}
);
public hassSubscribe() {
return [
subscribeLabelRegistry(this.hass.connection, (labels) => {
this._labelRegistry = labels;
}),
];
}
protected render(): TemplateResult { protected render(): TemplateResult {
const { map: labelMap, ids: labels } = this._labelsData(
this._labelRegistry,
this.device.labels,
this.hass.locale.language
);
return html` return html`
<ha-card <ha-card
outlined outlined
@@ -58,7 +105,7 @@ export class HaDeviceCard extends LitElement {
<span class="hub" <span class="hub"
><a ><a
href="/config/devices/device/${this.device.via_device_id}" href="/config/devices/device/${this.device.via_device_id}"
>${this._computeDeviceNameDislay( >${this._computeDeviceNameDisplay(
this.device.via_device_id this.device.via_device_id
)}</a )}</a
></span ></span
@@ -126,6 +173,34 @@ export class HaDeviceCard extends LitElement {
</div> </div>
` `
)} )}
${labels.length > 0
? html`
<div class="extra-info labels">
${labels.map((labelId) => {
const label = labelMap.get(labelId);
const color =
label?.color && typeof label.color === "string"
? computeCssColor(label.color)
: undefined;
return html`
<ha-label
style=${color ? `--color: ${color}` : ""}
.description=${label?.description}
>
${label?.icon
? html`<ha-icon
slot="icon"
.icon=${label.icon}
></ha-icon>`
: nothing}
${label?.name || labelId}
</ha-label>
`;
})}
</div>
`
: nothing}
<slot></slot> <slot></slot>
</div> </div>
<slot name="actions"></slot> <slot name="actions"></slot>
@@ -139,7 +214,7 @@ export class HaDeviceCard extends LitElement {
); );
} }
private _computeDeviceNameDislay(deviceId) { private _computeDeviceNameDisplay(deviceId: string) {
const device = this.hass.devices[deviceId]; const device = this.hass.devices[deviceId];
return device return device
? computeDeviceNameDisplay(device, this.hass) ? computeDeviceNameDisplay(device, this.hass)
@@ -162,8 +237,26 @@ export class HaDeviceCard extends LitElement {
.device { .device {
width: 30%; width: 30%;
} }
.labels {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-1);
width: 100%;
max-width: 100%;
}
.labels ha-label {
min-width: 0;
max-width: 100%;
flex: 0 1 auto;
}
ha-label {
--ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5;
--ha-label-text-color: var(--primary-text-color);
--ha-label-icon-color: var(--primary-text-color);
}
.extra-info { .extra-info {
margin-top: 8px; margin-top: var(--ha-space-2);
word-wrap: break-word; word-wrap: break-word;
} }
.manuf, .manuf,

View File

@@ -1,19 +1,19 @@
import { consume } from "@lit/context"; import { consume } from "@lit/context";
import { import {
mdiCancel,
mdiChevronRight, mdiChevronRight,
mdiDelete,
mdiDotsVertical, mdiDotsVertical,
mdiMenuDown, mdiMenuDown,
mdiPlus, mdiPlus,
mdiTextureBox, mdiTextureBox,
mdiCancel,
mdiDelete,
} from "@mdi/js"; } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit"; import { LitElement, css, html, nothing } from "lit";
import { ResizeController } from "@lit-labs/observers/resize-controller"; import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { customElement, property, state, query } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color"; import { computeCssColor } from "../../../common/color/compute-color";
import { formatShortDateTime } from "../../../common/datetime/format_date_time"; import { formatShortDateTime } from "../../../common/datetime/format_date_time";
@@ -68,8 +68,8 @@ import type {
DeviceRegistryEntry, DeviceRegistryEntry,
} from "../../../data/device_registry"; } from "../../../data/device_registry";
import { import {
updateDeviceRegistryEntry,
removeConfigEntryFromDevice, removeConfigEntryFromDevice,
updateDeviceRegistryEntry,
} from "../../../data/device_registry"; } from "../../../data/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity_registry"; import type { EntityRegistryEntry } from "../../../data/entity_registry";
import { import {
@@ -86,8 +86,8 @@ import {
showAlertDialog, showAlertDialog,
showConfirmationDialog, showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box"; } from "../../../dialogs/generic/show-dialog-box";
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
import "../../../layouts/hass-tabs-subpage-data-table"; import "../../../layouts/hass-tabs-subpage-data-table";
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types"; import type { HomeAssistant, Route } from "../../../types";
@@ -318,7 +318,7 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
}) })
); );
const deviceEntityLookup: DeviceEntityLookup = {}; const deviceEntityLookup: DeviceEntityLookup<EntityRegistryEntry> = {};
for (const entity of entities) { for (const entity of entities) {
if (!entity.device_id) { if (!entity.device_id) {
continue; continue;

View File

@@ -12,7 +12,7 @@ import "../../../lovelace/editor/dashboard-strategy-editor/hui-dashboard-strateg
import type { LovelaceDashboardConfigureStrategyDialogParams } from "./show-dialog-lovelace-dashboard-configure-strategy"; import type { LovelaceDashboardConfigureStrategyDialogParams } from "./show-dialog-lovelace-dashboard-configure-strategy";
@customElement("dialog-lovelace-dashboard-configure-strategy") @customElement("dialog-lovelace-dashboard-configure-strategy")
export class DialogLovelaceDashboardDetail extends LitElement { export class DialogLovelaceDashboardConfigureStrategy extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LovelaceDashboardConfigureStrategyDialogParams; @state() private _params?: LovelaceDashboardConfigureStrategyDialogParams;
@@ -97,6 +97,6 @@ export class DialogLovelaceDashboardDetail extends LitElement {
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"dialog-lovelace-dashboard-configure-strategy": DialogLovelaceDashboardDetail; "dialog-lovelace-dashboard-configure-strategy": DialogLovelaceDashboardConfigureStrategy;
} }
} }

View File

@@ -8,16 +8,13 @@ import "../../../../components/ha-button";
import { createCloseHeading } from "../../../../components/ha-dialog"; import { createCloseHeading } from "../../../../components/ha-dialog";
import "../../../../components/ha-form/ha-form"; import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types"; import type { SchemaUnion } from "../../../../components/ha-form/types";
import { saveFrontendSystemData } from "../../../../data/frontend";
import type { import type {
LovelaceDashboard, LovelaceDashboard,
LovelaceDashboardCreateParams, LovelaceDashboardCreateParams,
LovelaceDashboardMutableParams, LovelaceDashboardMutableParams,
} from "../../../../data/lovelace/dashboard"; } from "../../../../data/lovelace/dashboard";
import { DEFAULT_PANEL } from "../../../../data/panel";
import { haStyleDialog } from "../../../../resources/styles"; import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import { showConfirmationDialog } from "../../../lovelace/custom-card-helpers";
import type { LovelaceDashboardDetailsDialogParams } from "./show-dialog-lovelace-dashboard-detail"; import type { LovelaceDashboardDetailsDialogParams } from "./show-dialog-lovelace-dashboard-detail";
@customElement("dialog-lovelace-dashboard-detail") @customElement("dialog-lovelace-dashboard-detail")
@@ -61,9 +58,9 @@ export class DialogLovelaceDashboardDetail extends LitElement {
if (!this._params || !this._data) { if (!this._params || !this._data) {
return nothing; return nothing;
} }
const defaultPanelUrlPath =
this.hass.systemData?.default_panel || DEFAULT_PANEL;
const titleInvalid = !this._data.title || !this._data.title.trim(); const titleInvalid = !this._data.title || !this._data.title.trim();
const isLovelaceDashboard = this._params.urlPath === "lovelace";
return html` return html`
<ha-dialog <ha-dialog
@@ -88,9 +85,9 @@ export class DialogLovelaceDashboardDetail extends LitElement {
? this.hass.localize( ? this.hass.localize(
"ui.panel.config.lovelace.dashboards.cant_edit_yaml" "ui.panel.config.lovelace.dashboards.cant_edit_yaml"
) )
: this._params.urlPath === "lovelace" : isLovelaceDashboard
? this.hass.localize( ? this.hass.localize(
"ui.panel.config.lovelace.dashboards.cant_edit_default" "ui.panel.config.lovelace.dashboards.cant_edit_lovelace"
) )
: html` : html`
<ha-form <ha-form
@@ -119,24 +116,9 @@ export class DialogLovelaceDashboardDetail extends LitElement {
)} )}
</ha-button> </ha-button>
` `
: ""} : nothing}
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._toggleDefault}
.disabled=${this._params.urlPath === "lovelace" &&
defaultPanelUrlPath === "lovelace"}
>
${this._params.urlPath === defaultPanelUrlPath
? this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.remove_default"
)
: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.set_default"
)}
</ha-button>
` `
: ""} : nothing}
<ha-button <ha-button
slot="primaryAction" slot="primaryAction"
@click=${this._updateDashboard} @click=${this._updateDashboard}
@@ -254,40 +236,6 @@ export class DialogLovelaceDashboardDetail extends LitElement {
}; };
} }
private async _toggleDefault() {
const urlPath = this._params?.urlPath;
if (!urlPath) {
return;
}
const defaultPanel = this.hass.systemData?.default_panel || DEFAULT_PANEL;
// Add warning dialog to saying that this will change the default dashboard for all users
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
urlPath === defaultPanel
? "ui.panel.config.lovelace.dashboards.detail.remove_default_confirm_title"
: "ui.panel.config.lovelace.dashboards.detail.set_default_confirm_title"
),
text: this.hass.localize(
urlPath === defaultPanel
? "ui.panel.config.lovelace.dashboards.detail.remove_default_confirm_text"
: "ui.panel.config.lovelace.dashboards.detail.set_default_confirm_text"
),
confirmText: this.hass.localize("ui.common.ok"),
dismissText: this.hass.localize("ui.common.cancel"),
destructive: false,
});
if (!confirm) {
return;
}
saveFrontendSystemData(this.hass.connection, "core", {
...this.hass.systemData,
default_panel: urlPath === defaultPanel ? undefined : urlPath,
});
}
private async _updateDashboard() { private async _updateDashboard() {
if (this._params?.urlPath && !this._params.dashboard?.id) { if (this._params?.urlPath && !this._params.dashboard?.id) {
this.closeDialog(); this.closeDialog();

View File

@@ -1,8 +1,9 @@
import { import {
mdiCheck, mdiCheck,
mdiCheckCircleOutline,
mdiDelete, mdiDelete,
mdiDotsVertical, mdiDotsVertical,
mdiHomeCircleOutline,
mdiHomeEdit,
mdiPencil, mdiPencil,
mdiPlus, mdiPlus,
} from "@mdi/js"; } from "@mdi/js";
@@ -10,7 +11,6 @@ import type { PropertyValues } from "lit";
import { LitElement, html, nothing } from "lit"; import { LitElement, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import memoize from "memoize-one"; import memoize from "memoize-one";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { storage } from "../../../../common/decorators/storage"; import { storage } from "../../../../common/decorators/storage";
import { navigate } from "../../../../common/navigate"; import { navigate } from "../../../../common/navigate";
import { stringCompare } from "../../../../common/string/compare"; import { stringCompare } from "../../../../common/string/compare";
@@ -29,6 +29,7 @@ import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-list-item"; import "../../../../components/ha-md-list-item";
import "../../../../components/ha-svg-icon"; import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip"; import "../../../../components/ha-tooltip";
import { saveFrontendSystemData } from "../../../../data/frontend";
import type { LovelacePanelConfig } from "../../../../data/lovelace"; import type { LovelacePanelConfig } from "../../../../data/lovelace";
import type { LovelaceRawConfig } from "../../../../data/lovelace/config/types"; import type { LovelaceRawConfig } from "../../../../data/lovelace/config/types";
import { import {
@@ -45,7 +46,11 @@ import {
fetchDashboards, fetchDashboards,
updateDashboard, updateDashboard,
} from "../../../../data/lovelace/dashboard"; } from "../../../../data/lovelace/dashboard";
import { DEFAULT_PANEL } from "../../../../data/panel"; import {
DEFAULT_PANEL,
getPanelIcon,
getPanelTitle,
} from "../../../../data/panel";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box"; import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
import "../../../../layouts/hass-loading-screen"; import "../../../../layouts/hass-loading-screen";
import "../../../../layouts/hass-tabs-subpage-data-table"; import "../../../../layouts/hass-tabs-subpage-data-table";
@@ -56,12 +61,21 @@ import { lovelaceTabs } from "../ha-config-lovelace";
import { showDashboardConfigureStrategyDialog } from "./show-dialog-lovelace-dashboard-configure-strategy"; import { showDashboardConfigureStrategyDialog } from "./show-dialog-lovelace-dashboard-configure-strategy";
import { showDashboardDetailDialog } from "./show-dialog-lovelace-dashboard-detail"; import { showDashboardDetailDialog } from "./show-dialog-lovelace-dashboard-detail";
export const PANEL_DASHBOARDS = [
"home",
"light",
"security",
"climate",
"energy",
] as string[];
type DataTableItem = Pick< type DataTableItem = Pick<
LovelaceDashboard, LovelaceDashboard,
"icon" | "title" | "show_in_sidebar" | "require_admin" | "mode" | "url_path" "icon" | "title" | "show_in_sidebar" | "require_admin" | "mode" | "url_path"
> & { > & {
default: boolean; default: boolean;
filename: string; filename: string;
localized_type: string;
type: string; type: string;
}; };
@@ -112,7 +126,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
state: false, state: false,
subscribe: false, subscribe: false,
}) })
private _activeGrouping?: string = "type"; private _activeGrouping?: string = "localized_type";
@storage({ @storage({
key: "lovelace-dashboards-table-collapsed", key: "lovelace-dashboards-table-collapsed",
@@ -167,7 +181,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
<ha-svg-icon <ha-svg-icon
.id="default-icon-${dashboard.title}" .id="default-icon-${dashboard.title}"
style="padding-left: 10px; padding-inline-start: 10px; padding-inline-end: initial; direction: var(--direction);" style="padding-left: 10px; padding-inline-start: 10px; padding-inline-end: initial; direction: var(--direction);"
.path=${mdiCheckCircleOutline} .path=${mdiHomeCircleOutline}
></ha-svg-icon> ></ha-svg-icon>
<ha-tooltip <ha-tooltip
.for="default-icon-${dashboard.title}" .for="default-icon-${dashboard.title}"
@@ -183,7 +197,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
}, },
}; };
columns.type = { columns.localized_type = {
title: localize( title: localize(
"ui.panel.config.lovelace.dashboards.picker.headers.type" "ui.panel.config.lovelace.dashboards.picker.headers.type"
), ),
@@ -253,7 +267,15 @@ export class HaConfigLovelaceDashboards extends LitElement {
.hass=${this.hass} .hass=${this.hass}
narrow narrow
.items=${[ .items=${[
...(this._canEdit(dashboard.url_path) {
path: mdiHomeEdit,
label: localize(
"ui.panel.config.lovelace.dashboards.picker.set_as_default"
),
action: () => this._handleSetAsDefault(dashboard),
disabled: dashboard.default,
},
...(dashboard.type === "user_created"
? [ ? [
{ {
path: mdiPencil, path: mdiPencil,
@@ -262,10 +284,6 @@ export class HaConfigLovelaceDashboards extends LitElement {
), ),
action: () => this._handleEdit(dashboard), action: () => this._handleEdit(dashboard),
}, },
]
: []),
...(this._canDelete(dashboard.url_path)
? [
{ {
label: this.hass.localize( label: this.hass.localize(
"ui.panel.config.lovelace.dashboards.picker.delete" "ui.panel.config.lovelace.dashboards.picker.delete"
@@ -288,92 +306,43 @@ export class HaConfigLovelaceDashboards extends LitElement {
private _getItems = memoize( private _getItems = memoize(
(dashboards: LovelaceDashboard[], defaultUrlPath: string | null) => { (dashboards: LovelaceDashboard[], defaultUrlPath: string | null) => {
const defaultMode = ( const mode = (this.hass.panels?.lovelace?.config as LovelacePanelConfig)
this.hass.panels?.lovelace?.config as LovelacePanelConfig .mode;
).mode;
const isDefault = defaultUrlPath === "lovelace"; const isDefault = defaultUrlPath === "lovelace";
const result: DataTableItem[] = [ const result: DataTableItem[] = [
{ {
icon: "mdi:view-dashboard", icon: "mdi:view-dashboard",
title: this.hass.localize("panel.states"), title: this.hass.localize("panel.states"),
default: isDefault, default: isDefault,
show_in_sidebar: isDefault, show_in_sidebar: true,
require_admin: false, require_admin: false,
url_path: "lovelace", url_path: "lovelace",
mode: defaultMode, mode: mode,
filename: defaultMode === "yaml" ? "ui-lovelace.yaml" : "", filename: mode === "yaml" ? "ui-lovelace.yaml" : "",
type: this._localizeType("built_in"), type: "built_in",
localized_type: this._localizeType("built_in"),
}, },
]; ];
if (isComponentLoaded(this.hass, "energy")) {
result.push({
icon: "mdi:lightning-bolt",
title: this.hass.localize(`ui.panel.config.dashboard.energy.main`),
show_in_sidebar: true,
mode: "storage",
url_path: "energy",
filename: "",
default: false,
require_admin: false,
type: this._localizeType("built_in"),
});
}
if (this.hass.panels.light) { PANEL_DASHBOARDS.forEach((panel) => {
result.push({ const panelInfo = this.hass.panels[panel];
icon: this.hass.panels.light.icon || "mdi:lamps", if (!panel) {
title: this.hass.localize("panel.light"), return;
}
const item: DataTableItem = {
icon: getPanelIcon(panelInfo),
title: getPanelTitle(this.hass, panelInfo) || panelInfo.url_path,
show_in_sidebar: true, show_in_sidebar: true,
mode: "storage", mode: "storage",
url_path: "light", url_path: panelInfo.url_path,
filename: "", filename: "",
default: false, default: defaultUrlPath === panelInfo.url_path,
require_admin: false, require_admin: false,
type: this._localizeType("built_in"), type: "built_in",
}); localized_type: this._localizeType("built_in"),
} };
result.push(item);
if (this.hass.panels.security) { });
result.push({
icon: this.hass.panels.security.icon || "mdi:security",
title: this.hass.localize("panel.security"),
show_in_sidebar: true,
mode: "storage",
url_path: "security",
filename: "",
default: false,
require_admin: false,
type: this._localizeType("built_in"),
});
}
if (this.hass.panels.climate) {
result.push({
icon: this.hass.panels.climate.icon || "mdi:home-thermometer",
title: this.hass.localize("panel.climate"),
show_in_sidebar: true,
mode: "storage",
url_path: "climate",
filename: "",
default: false,
require_admin: false,
type: this._localizeType("built_in"),
});
}
if (this.hass.panels.home) {
result.push({
icon: this.hass.panels.home.icon || "mdi:home",
title: this.hass.localize("panel.home"),
show_in_sidebar: true,
mode: "storage",
url_path: "home",
filename: "",
default: false,
require_admin: false,
type: this._localizeType("built_in"),
});
}
result.push( result.push(
...dashboards ...dashboards
@@ -386,7 +355,8 @@ export class HaConfigLovelaceDashboards extends LitElement {
filename: "", filename: "",
...dashboard, ...dashboard,
default: defaultUrlPath === dashboard.url_path, default: defaultUrlPath === dashboard.url_path,
type: this._localizeType("user_created"), type: "user_created",
localized_type: this._localizeType("user_created"),
}) satisfies DataTableItem }) satisfies DataTableItem
) )
); );
@@ -486,20 +456,32 @@ export class HaConfigLovelaceDashboards extends LitElement {
this._openDetailDialog(dashboard, urlPath); this._openDetailDialog(dashboard, urlPath);
} }
private _canDelete(urlPath: string) { private _handleSetAsDefault = async (item: DataTableItem) => {
return ![ if (item.default) {
"lovelace", return;
"energy", }
"light",
"security",
"climate",
"home",
].includes(urlPath);
}
private _canEdit(urlPath: string) { const confirm = await showConfirmationDialog(this, {
return !["light", "security", "climate", "home"].includes(urlPath); title: this.hass.localize(
} "ui.panel.config.lovelace.dashboards.detail.set_default_confirm_title"
),
text: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.set_default_confirm_text"
),
confirmText: this.hass.localize("ui.common.ok"),
dismissText: this.hass.localize("ui.common.cancel"),
destructive: false,
});
if (!confirm) {
return;
}
await saveFrontendSystemData(this.hass.connection, "core", {
...this.hass.systemData,
default_panel: item.url_path,
});
};
private _handleDelete = async (item: DataTableItem) => { private _handleDelete = async (item: DataTableItem) => {
const dashboard = this._dashboards.find( const dashboard = this._dashboards.find(
@@ -581,10 +563,6 @@ export class HaConfigLovelaceDashboards extends LitElement {
private async _deleteDashboard( private async _deleteDashboard(
dashboard: LovelaceDashboard dashboard: LovelaceDashboard
): Promise<boolean> { ): Promise<boolean> {
if (!this._canDelete(dashboard.url_path)) {
return false;
}
const confirm = await showConfirmationDialog(this, { const confirm = await showConfirmationDialog(this, {
title: this.hass!.localize( title: this.hass!.localize(
"ui.panel.config.lovelace.dashboards.confirm_delete_title", "ui.panel.config.lovelace.dashboards.confirm_delete_title",

View File

@@ -271,7 +271,6 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
}, },
area: { area: {
title: localize("ui.panel.config.scene.picker.headers.area"), title: localize("ui.panel.config.scene.picker.headers.area"),
defaultHidden: true,
groupable: true, groupable: true,
filterable: true, filterable: true,
sortable: true, sortable: true,

View File

@@ -281,7 +281,6 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
}, },
area: { area: {
title: localize("ui.panel.config.script.picker.headers.area"), title: localize("ui.panel.config.script.picker.headers.area"),
defaultHidden: true,
groupable: true, groupable: true,
filterable: true, filterable: true,
sortable: true, sortable: true,

View File

@@ -270,6 +270,7 @@ export class HaManualScriptEditor extends LitElement {
@value-changed=${this._sidebarConfigChanged} @value-changed=${this._sidebarConfigChanged}
@sidebar-resized=${this._resizeSidebar} @sidebar-resized=${this._resizeSidebar}
@sidebar-resizing-stopped=${this._stopResizeSidebar} @sidebar-resizing-stopped=${this._stopResizeSidebar}
@sidebar-reset-size=${this._resetSidebarWidth}
></ha-automation-sidebar> ></ha-automation-sidebar>
</div> </div>
</div> </div>
@@ -618,6 +619,16 @@ export class HaManualScriptEditor extends LitElement {
this._prevSidebarWidthPx = undefined; this._prevSidebarWidthPx = undefined;
} }
private _resetSidebarWidth(ev: Event) {
ev.stopPropagation();
this._prevSidebarWidthPx = undefined;
this._sidebarWidthPx = SIDEBAR_DEFAULT_WIDTH;
this.style.setProperty(
"--sidebar-dynamic-width",
`${this._sidebarWidthPx}px`
);
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
saveFabStyles, saveFabStyles,

View File

@@ -6,6 +6,7 @@ import {
import { LitElement, css, html } from "lit"; import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat"; import { repeat } from "lit/directives/repeat";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { formatDateTimeWithSeconds } from "../../../../common/datetime/format_date_time"; import { formatDateTimeWithSeconds } from "../../../../common/datetime/format_date_time";
import type { import type {
PipelineRunEvent, PipelineRunEvent,
@@ -20,6 +21,8 @@ import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles"; import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../types"; import type { HomeAssistant, Route } from "../../../../types";
import "./assist-render-pipeline-events"; import "./assist-render-pipeline-events";
import type { ChatLog } from "../../../../data/chat_log";
import { subscribeChatLog } from "../../../../data/chat_log";
@customElement("assist-pipeline-debug") @customElement("assist-pipeline-debug")
export class AssistPipelineDebug extends LitElement { export class AssistPipelineDebug extends LitElement {
@@ -37,8 +40,12 @@ export class AssistPipelineDebug extends LitElement {
@state() private _events?: PipelineRunEvent[]; @state() private _events?: PipelineRunEvent[];
@state() private _chatLog?: ChatLog;
private _unsubRefreshEventsID?: number; private _unsubRefreshEventsID?: number;
private _unsubChatLogUpdates?: Promise<UnsubscribeFunc>;
protected render() { protected render() {
return html`<hass-subpage return html`<hass-subpage
.narrow=${this.narrow} .narrow=${this.narrow}
@@ -106,6 +113,7 @@ export class AssistPipelineDebug extends LitElement {
? html`<assist-render-pipeline-events ? html`<assist-render-pipeline-events
.hass=${this.hass} .hass=${this.hass}
.events=${this._events} .events=${this._events}
.chatLog=${this._chatLog}
></assist-render-pipeline-events>` ></assist-render-pipeline-events>`
: ""} : ""}
</div> </div>
@@ -120,6 +128,10 @@ export class AssistPipelineDebug extends LitElement {
clearRefresh = true; clearRefresh = true;
} }
if (changedProperties.has("_runId")) { if (changedProperties.has("_runId")) {
if (this._unsubChatLogUpdates) {
this._unsubChatLogUpdates.then((unsub) => unsub());
this._unsubChatLogUpdates = undefined;
}
this._fetchEvents(); this._fetchEvents();
clearRefresh = true; clearRefresh = true;
} }
@@ -135,6 +147,10 @@ export class AssistPipelineDebug extends LitElement {
clearTimeout(this._unsubRefreshEventsID); clearTimeout(this._unsubRefreshEventsID);
this._unsubRefreshEventsID = undefined; this._unsubRefreshEventsID = undefined;
} }
if (this._unsubChatLogUpdates) {
this._unsubChatLogUpdates.then((unsub) => unsub());
this._unsubChatLogUpdates = undefined;
}
} }
private async _fetchRuns() { private async _fetchRuns() {
@@ -185,8 +201,27 @@ export class AssistPipelineDebug extends LitElement {
}); });
return; return;
} }
if (!this._events!.length) {
return;
}
if (!this._unsubChatLogUpdates && this._events[0].type === "run-start") {
this._unsubChatLogUpdates = subscribeChatLog(
this.hass,
this._events[0].data.conversation_id,
(chatLog) => {
if (chatLog) {
this._chatLog = chatLog;
} else {
this._unsubChatLogUpdates?.then((unsub) => unsub());
this._unsubChatLogUpdates = undefined;
}
}
);
this._unsubChatLogUpdates.catch(() => {
this._unsubChatLogUpdates = undefined;
});
}
if ( if (
this._events?.length &&
// If the last event is not a finish run event, the run is still ongoing. // If the last event is not a finish run event, the run is still ongoing.
// Refresh events automatically. // Refresh events automatically.
!["run-end", "error"].includes(this._events[this._events.length - 1].type) !["run-end", "error"].includes(this._events[this._events.length - 1].type)

View File

@@ -1,6 +1,7 @@
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit"; import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { extractSearchParam } from "../../../../common/url/search-params"; import { extractSearchParam } from "../../../../common/url/search-params";
import "../../../../components/ha-assist-pipeline-picker"; import "../../../../components/ha-assist-pipeline-picker";
import "../../../../components/ha-button"; import "../../../../components/ha-button";
@@ -24,6 +25,8 @@ import type { HomeAssistant } from "../../../../types";
import { AudioRecorder } from "../../../../util/audio-recorder"; import { AudioRecorder } from "../../../../util/audio-recorder";
import { fileDownload } from "../../../../util/file_download"; import { fileDownload } from "../../../../util/file_download";
import "./assist-render-pipeline-run"; import "./assist-render-pipeline-run";
import type { ChatLog } from "../../../../data/chat_log";
import { subscribeChatLog } from "../../../../data/chat_log";
@customElement("assist-pipeline-run-debug") @customElement("assist-pipeline-run-debug")
export class AssistPipelineRunDebug extends LitElement { export class AssistPipelineRunDebug extends LitElement {
@@ -46,6 +49,13 @@ export class AssistPipelineRunDebug extends LitElement {
@state() private _pipelineId?: string = @state() private _pipelineId?: string =
extractSearchParam("pipeline") || undefined; extractSearchParam("pipeline") || undefined;
@state() private _chatLog?: ChatLog;
private _chatLogSubscription: {
conversationId: string;
unsub: Promise<UnsubscribeFunc>;
} | null = null;
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<hass-subpage <hass-subpage
@@ -178,6 +188,7 @@ export class AssistPipelineRunDebug extends LitElement {
<assist-render-pipeline-run <assist-render-pipeline-run
.hass=${this.hass} .hass=${this.hass}
.pipelineRun=${run} .pipelineRun=${run}
.chatLog=${this._chatLog}
></assist-render-pipeline-run> ></assist-render-pipeline-run>
` `
)} )}
@@ -186,6 +197,14 @@ export class AssistPipelineRunDebug extends LitElement {
`; `;
} }
public disconnectedCallback(): void {
super.disconnectedCallback();
if (this._chatLogSubscription) {
this._chatLogSubscription.unsub.then((unsub) => unsub());
this._chatLogSubscription = null;
}
}
private get conversationId(): string | null { private get conversationId(): string | null {
return this._pipelineRuns.length === 0 return this._pipelineRuns.length === 0
? null ? null
@@ -408,6 +427,32 @@ export class AssistPipelineRunDebug extends LitElement {
added = true; added = true;
} }
callback(updatedRun); callback(updatedRun);
const conversationId = this.conversationId;
if (
!this._chatLog &&
conversationId &&
(!this._chatLogSubscription ||
this._chatLogSubscription.conversationId !== conversationId)
) {
if (this._chatLogSubscription) {
this._chatLogSubscription.unsub.then((unsub) => unsub());
}
this._chatLogSubscription = {
conversationId,
unsub: subscribeChatLog(this.hass, conversationId, (chatLog) => {
if (chatLog) {
this._chatLog = chatLog;
} else {
this._chatLogSubscription?.unsub.then((unsub) => unsub());
this._chatLogSubscription = null;
}
}),
};
this._chatLogSubscription.unsub.catch(() => {
this._chatLogSubscription = null;
});
}
}, },
{ {
...options, ...options,

View File

@@ -9,6 +9,7 @@ import type {
import { processEvent } from "../../../../data/assist_pipeline"; import { processEvent } from "../../../../data/assist_pipeline";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import "./assist-render-pipeline-run"; import "./assist-render-pipeline-run";
import type { ChatLog } from "../../../../data/chat_log";
@customElement("assist-render-pipeline-events") @customElement("assist-render-pipeline-events")
export class AssistPipelineEvents extends LitElement { export class AssistPipelineEvents extends LitElement {
@@ -16,6 +17,8 @@ export class AssistPipelineEvents extends LitElement {
@property({ attribute: false }) public events!: PipelineRunEvent[]; @property({ attribute: false }) public events!: PipelineRunEvent[];
@property({ attribute: false }) public chatLog?: ChatLog;
private _processEvents = memoizeOne( private _processEvents = memoizeOne(
(events: PipelineRunEvent[]): PipelineRun | undefined => { (events: PipelineRunEvent[]): PipelineRun | undefined => {
let run: PipelineRun | undefined; let run: PipelineRun | undefined;
@@ -56,6 +59,7 @@ export class AssistPipelineEvents extends LitElement {
<assist-render-pipeline-run <assist-render-pipeline-run
.hass=${this.hass} .hass=${this.hass}
.pipelineRun=${run} .pipelineRun=${run}
.chatLog=${this.chatLog}
></assist-render-pipeline-run> ></assist-render-pipeline-run>
`; `;
} }

View File

@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import "../../../../components/ha-card"; import "../../../../components/ha-card";
import "../../../../components/ha-alert"; import "../../../../components/ha-alert";
@@ -12,6 +12,12 @@ import { formatNumber } from "../../../../common/number/format_number";
import "../../../../components/ha-yaml-editor"; import "../../../../components/ha-yaml-editor";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box"; import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import type { LocalizeKeys } from "../../../../common/translations/localize"; import type { LocalizeKeys } from "../../../../common/translations/localize";
import type {
ChatLogAssistantContent,
ChatLog,
ChatLogContent,
ChatLogUserContent,
} from "../../../../data/chat_log";
const RUN_DATA = ["pipeline", "language"]; const RUN_DATA = ["pipeline", "language"];
const WAKE_WORD_DATA = ["engine"]; const WAKE_WORD_DATA = ["engine"];
@@ -119,7 +125,7 @@ const dataMinusKeysRender = (
result[key] = data[key]; result[key] = data[key];
} }
return render return render
? html`<ha-expansion-panel> ? html`<ha-expansion-panel class="yaml-expansion">
<span slot="header" <span slot="header"
>${hass.localize("ui.panel.config.voice_assistants.debug.raw")}</span >${hass.localize("ui.panel.config.voice_assistants.debug.raw")}</span
> >
@@ -134,6 +140,8 @@ export class AssistPipelineDebug extends LitElement {
@property({ attribute: false }) public pipelineRun!: PipelineRun; @property({ attribute: false }) public pipelineRun!: PipelineRun;
@property({ attribute: false }) public chatLog?: ChatLog;
private _audioElement?: HTMLAudioElement; private _audioElement?: HTMLAudioElement;
private get _isPlaying(): boolean { private get _isPlaying(): boolean {
@@ -147,31 +155,47 @@ export class AssistPipelineDebug extends LitElement {
) || "ready" ) || "ready"
: "ready"; : "ready";
const messages: { from: string; text: string }[] = []; let messages: ChatLogContent[];
const userMessage = if (this.chatLog) {
(this.pipelineRun.init_options && messages = this.chatLog.content.filter(
"text" in this.pipelineRun.init_options.input this.pipelineRun.finished
? this.pipelineRun.init_options.input.text ? (content: ChatLogContent) =>
: undefined) || content.role === "system" ||
this.pipelineRun?.stt?.stt_output?.text || (content.created >= this.pipelineRun.started &&
this.pipelineRun?.intent?.intent_input; content.created <= this.pipelineRun.finished!)
: (content: ChatLogContent) =>
content.role === "system" ||
content.created >= this.pipelineRun.started
);
} else {
messages = [];
if (userMessage) { // We don't have the chat log everywhere yet, just fallback for now.
messages.push({ const userMessage =
from: "user", (this.pipelineRun.init_options &&
text: userMessage, "text" in this.pipelineRun.init_options.input
}); ? this.pipelineRun.init_options.input.text
} : undefined) ||
this.pipelineRun?.stt?.stt_output?.text ||
this.pipelineRun?.intent?.intent_input;
if ( if (userMessage) {
this.pipelineRun?.intent?.intent_output?.response?.speech?.plain?.speech messages.push({
) { role: "user",
messages.push({ content: userMessage,
from: "hass", } as ChatLogUserContent);
text: this.pipelineRun.intent.intent_output.response.speech.plain }
.speech,
}); if (
this.pipelineRun?.intent?.intent_output?.response?.speech?.plain?.speech
) {
messages.push({
role: "assistant",
content:
this.pipelineRun.intent.intent_output.response.speech.plain.speech,
} as ChatLogAssistantContent);
}
} }
return html` return html`
@@ -190,10 +214,58 @@ export class AssistPipelineDebug extends LitElement {
${messages.length > 0 ${messages.length > 0
? html` ? html`
<div class="messages"> <div class="messages">
${messages.map( ${messages.map((content) =>
({ from, text }) => html` content.role === "system" || content.role === "tool_result"
<div class=${`message ${from}`}>${text}</div> ? html`
` <ha-expansion-panel
class="content-expansion ${content.role}"
>
<div slot="header">
${content.role === "system"
? "System"
: `Result for ${content.tool_name}`}
</div>
${content.role === "system"
? html`<pre>${content.content}</pre>`
: html`
<ha-yaml-editor
read-only
auto-update
.value=${content}
></ha-yaml-editor>
`}
</ha-expansion-panel>
`
: html`
${content.content
? html`
<div class=${`message ${content.role}`}>
${content.content}
</div>
`
: nothing}
${content.role === "assistant" &&
content.tool_calls?.length
? html`
<ha-expansion-panel
class="content-expansion assistant"
>
<span slot="header">
Call
${content.tool_calls.length === 1
? content.tool_calls[0].tool_name
: `${content.tool_calls.length} tools`}
</span>
<ha-yaml-editor
read-only
auto-update
.value=${content.tool_calls}
></ha-yaml-editor>
</ha-expansion-panel>
`
: nothing}
`
)} )}
</div> </div>
<div style="clear:both"></div> <div style="clear:both"></div>
@@ -442,7 +514,7 @@ export class AssistPipelineDebug extends LitElement {
: ""} : ""}
${maybeRenderError(this.pipelineRun, "tts", lastRunStage)} ${maybeRenderError(this.pipelineRun, "tts", lastRunStage)}
<ha-card> <ha-card>
<ha-expansion-panel> <ha-expansion-panel class="yaml-expansion">
<span slot="header" <span slot="header"
>${this.hass.localize( >${this.hass.localize(
"ui.panel.config.voice_assistants.debug.raw" "ui.panel.config.voice_assistants.debug.raw"
@@ -519,12 +591,12 @@ export class AssistPipelineDebug extends LitElement {
.row > div:last-child { .row > div:last-child {
text-align: right; text-align: right;
} }
ha-expansion-panel { .yaml-expansion {
padding-left: 8px; padding-left: 8px;
padding-inline-start: 8px; padding-inline-start: 8px;
padding-inline-end: initial; padding-inline-end: initial;
} }
.card-content ha-expansion-panel { .card-content .yaml-expansion {
padding-left: 0px; padding-left: 0px;
padding-inline-start: 0px; padding-inline-start: 0px;
padding-inline-end: initial; padding-inline-end: initial;
@@ -540,27 +612,59 @@ export class AssistPipelineDebug extends LitElement {
margin-top: 8px; margin-top: 8px;
} }
.content-expansion {
margin: 8px 0;
border-radius: var(--ha-border-radius-xl);
clear: both;
padding: 0 8px;
--input-fill-color: none;
max-width: calc(100% - 24px);
--expansion-panel-summary-padding: 0px;
--expansion-panel-content-padding: 0px;
}
.content-expansion *[slot="header"] {
font-weight: var(--ha-font-weight-normal);
}
.system {
background-color: var(--success-color);
}
.message { .message {
padding: 8px;
}
.message,
.content-expansion {
font-size: var(--ha-font-size-l); font-size: var(--ha-font-size-l);
margin: 8px 0; margin: 8px 0;
padding: 8px;
border-radius: var(--ha-border-radius-xl); border-radius: var(--ha-border-radius-xl);
clear: both; clear: both;
} }
.message.user { .messages pre {
white-space: pre-wrap;
}
.user,
.tool_result {
margin-left: 24px; margin-left: 24px;
margin-inline-start: 24px; margin-inline-start: 24px;
margin-inline-end: initial; margin-inline-end: initial;
float: var(--float-end); float: var(--float-end);
text-align: right;
border-bottom-right-radius: 0px; border-bottom-right-radius: 0px;
background-color: var(--light-primary-color); background-color: var(--light-primary-color);
color: var(--text-light-primary-color, var(--primary-text-color)); color: var(--text-light-primary-color, var(--primary-text-color));
direction: var(--direction); direction: var(--direction);
} }
.message.hass { .message.user,
.content-expansion div[slot="header"] {
text-align: right;
}
.assistant {
margin-right: 24px; margin-right: 24px;
margin-inline-end: 24px; margin-inline-end: 24px;
margin-inline-start: initial; margin-inline-start: initial;

View File

@@ -14,6 +14,7 @@ import { getEnergyColor } from "./common/color";
import { formatNumber } from "../../../../common/number/format_number"; import { formatNumber } from "../../../../common/number/format_number";
import "../../../../components/chart/ha-chart-base"; import "../../../../components/chart/ha-chart-base";
import "../../../../components/ha-card"; import "../../../../components/ha-card";
import "./common/hui-energy-graph-chip";
import type { import type {
EnergyData, EnergyData,
EnergySumData, EnergySumData,
@@ -67,6 +68,8 @@ export class HuiEnergyUsageGraphCard
@state() private _compareEnd?: Date; @state() private _compareEnd?: Date;
@state() private _total?: number;
protected hassSubscribeRequiredHostProps = ["_config"]; protected hassSubscribeRequiredHostProps = ["_config"];
public hassSubscribe(): UnsubscribeFunc[] { public hassSubscribe(): UnsubscribeFunc[] {
@@ -100,9 +103,19 @@ export class HuiEnergyUsageGraphCard
return html` return html`
<ha-card> <ha-card>
${this._config.title <div class="card-header">
? html`<h1 class="card-header">${this._config.title}</h1>` <span>${this._config.title ? this._config.title : nothing}</span>
: ""} ${this._total
? html`<hui-energy-graph-chip
.tooltip=${this._formatTotal(this._total)}
>
${this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_usage_graph.total_usage",
{ num: formatNumber(this._total, this.hass.locale) }
)}
</hui-energy-graph-chip>`
: nothing}
</div>
<div <div
class="content ${classMap({ class="content ${classMap({
"has-header": !!this._config.title, "has-header": !!this._config.title,
@@ -338,6 +351,13 @@ export class HuiEnergyUsageGraphCard
datasets.sort((a, b) => a.order - b.order); datasets.sort((a, b) => a.order - b.order);
fillDataGapsAndRoundCaps(datasets); fillDataGapsAndRoundCaps(datasets);
this._chartData = datasets; this._chartData = datasets;
this._total = this._processTotal(consumption);
}
private _processTotal(consumption: EnergyConsumptionData) {
return consumption.total.used_total > 0
? consumption.total.used_total
: undefined;
} }
private _processDataSet( private _processDataSet(
@@ -515,6 +535,9 @@ export class HuiEnergyUsageGraphCard
height: 100%; height: 100%;
} }
.card-header { .card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0; padding-bottom: 0;
} }
.content { .content {

View File

@@ -2,6 +2,7 @@ import type { HomeAssistant } from "../../../types";
import type { Lovelace } from "../types"; import type { Lovelace } from "../types";
import { deleteBadge } from "./config-util"; import { deleteBadge } from "./config-util";
import type { LovelaceCardPath } from "./lovelace-path"; import type { LovelaceCardPath } from "./lovelace-path";
import { fireEvent } from "../../../common/dom/fire_event";
export interface DeleteBadgeParams { export interface DeleteBadgeParams {
path: LovelaceCardPath; path: LovelaceCardPath;
@@ -23,14 +24,13 @@ export async function performDeleteBadge(
return; return;
} }
const action = async () => {
lovelace.saveConfig(oldConfig);
};
lovelace.showToast({ lovelace.showToast({
message: hass.localize("ui.common.successfully_deleted"), message: hass.localize("ui.common.successfully_deleted"),
duration: 8000, duration: 8000,
action: { action, text: hass.localize("ui.common.undo") }, action: {
action: () => fireEvent(window, "undo-change"),
text: hass.localize("ui.common.undo"),
},
}); });
} catch (err: any) { } catch (err: any) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console

View File

@@ -2,6 +2,7 @@ import type { HomeAssistant } from "../../../types";
import type { Lovelace } from "../types"; import type { Lovelace } from "../types";
import { deleteCard } from "./config-util"; import { deleteCard } from "./config-util";
import type { LovelaceCardPath } from "./lovelace-path"; import type { LovelaceCardPath } from "./lovelace-path";
import { fireEvent } from "../../../common/dom/fire_event";
export interface DeleteCardParams { export interface DeleteCardParams {
path: LovelaceCardPath; path: LovelaceCardPath;
@@ -23,14 +24,13 @@ export async function performDeleteCard(
return; return;
} }
const action = async () => {
lovelace.saveConfig(oldConfig);
};
lovelace.showToast({ lovelace.showToast({
message: hass.localize("ui.common.successfully_deleted"), message: hass.localize("ui.common.successfully_deleted"),
duration: 8000, duration: 8000,
action: { action, text: hass.localize("ui.common.undo") }, action: {
action: () => fireEvent(window, "undo-change"),
text: hass.localize("ui.common.undo"),
},
}); });
} catch (err: any) { } catch (err: any) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console

View File

@@ -11,10 +11,12 @@ import {
mdiMagnify, mdiMagnify,
mdiPencil, mdiPencil,
mdiPlus, mdiPlus,
mdiRedo,
mdiRefresh, mdiRefresh,
mdiRobot, mdiRobot,
mdiShape, mdiShape,
mdiSofa, mdiSofa,
mdiUndo,
mdiViewDashboard, mdiViewDashboard,
} from "@mdi/js"; } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
@@ -50,7 +52,10 @@ import "../../components/ha-tab-group-tab";
import "../../components/ha-tooltip"; import "../../components/ha-tooltip";
import { createAreaRegistryEntry } from "../../data/area_registry"; import { createAreaRegistryEntry } from "../../data/area_registry";
import type { LovelacePanelConfig } from "../../data/lovelace"; import type { LovelacePanelConfig } from "../../data/lovelace";
import type { LovelaceConfig } from "../../data/lovelace/config/types"; import type {
LovelaceConfig,
LovelaceRawConfig,
} from "../../data/lovelace/config/types";
import { isStrategyDashboard } from "../../data/lovelace/config/types"; import { isStrategyDashboard } from "../../data/lovelace/config/types";
import type { LovelaceViewConfig } from "../../data/lovelace/config/view"; import type { LovelaceViewConfig } from "../../data/lovelace/config/view";
import { import {
@@ -92,6 +97,7 @@ import "./views/hui-view";
import type { HUIView } from "./views/hui-view"; import type { HUIView } from "./views/hui-view";
import "./views/hui-view-background"; import "./views/hui-view-background";
import "./views/hui-view-container"; import "./views/hui-view-container";
import { UndoRedoController } from "../../common/controllers/undo-redo-controller";
interface ActionItem { interface ActionItem {
icon: string; icon: string;
@@ -113,6 +119,11 @@ interface SubActionItem {
visible: boolean | undefined; visible: boolean | undefined;
} }
interface UndoStackItem {
location: string;
config: LovelaceRawConfig;
}
@customElement("hui-root") @customElement("hui-root")
class HUIRoot extends LitElement { class HUIRoot extends LitElement {
@property({ attribute: false }) public panel?: PanelInfo<LovelacePanelConfig>; @property({ attribute: false }) public panel?: PanelInfo<LovelacePanelConfig>;
@@ -130,12 +141,22 @@ class HUIRoot extends LitElement {
@state() private _curView?: number | "hass-unused-entities"; @state() private _curView?: number | "hass-unused-entities";
private _configChangedByUndo = false;
private _viewCache?: Record<string, HUIView>; private _viewCache?: Record<string, HUIView>;
private _viewScrollPositions: Record<string, number> = {}; private _viewScrollPositions: Record<string, number> = {};
private _restoreScroll = false; private _restoreScroll = false;
private _undoRedoController = new UndoRedoController<UndoStackItem>(this, {
apply: (config) => this._applyUndoRedo(config),
currentConfig: () => ({
location: this.route!.path,
config: this.lovelace!.rawConfig,
}),
});
private _debouncedConfigChanged: () => void; private _debouncedConfigChanged: () => void;
private _conversation = memoizeOne((_components) => private _conversation = memoizeOne((_components) =>
@@ -157,7 +178,29 @@ class HUIRoot extends LitElement {
const result: TemplateResult[] = []; const result: TemplateResult[] = [];
if (this._editMode) { if (this._editMode) {
result.push( result.push(
html`<ha-button html`<ha-icon-button
slot="toolbar-icon"
.path=${mdiUndo}
@click=${this._undo}
.disabled=${!this._undoRedoController.canUndo}
id="button-undo"
>
</ha-icon-button>
<ha-tooltip placement="bottom" for="button-undo">
${this.hass.localize("ui.common.undo")}
</ha-tooltip>
<ha-icon-button
slot="toolbar-icon"
.path=${mdiRedo}
@click=${this._redo}
.disabled=${!this._undoRedoController.canRedo}
id="button-redo"
>
</ha-icon-button>
<ha-tooltip placement="bottom" for="button-redo">
${this.hass.localize("ui.common.redo")}
</ha-tooltip>
<ha-button
appearance="filled" appearance="filled"
size="small" size="small"
class="exit-edit-mode" class="exit-edit-mode"
@@ -645,6 +688,27 @@ class HUIRoot extends LitElement {
window.history.scrollRestoration = "auto"; window.history.scrollRestoration = "auto";
} }
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has("lovelace")) {
const oldLovelace = changedProperties.get("lovelace") as
| Lovelace
| undefined;
if (
oldLovelace &&
this.lovelace!.rawConfig !== oldLovelace!.rawConfig &&
!this._configChangedByUndo
) {
this._undoRedoController.commit({
location: this.route!.path,
config: oldLovelace.rawConfig,
});
} else {
this._configChangedByUndo = false;
}
}
}
protected updated(changedProperties: PropertyValues): void { protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties); super.updated(changedProperties);
@@ -1029,6 +1093,7 @@ class HUIRoot extends LitElement {
private _editModeDisable(): void { private _editModeDisable(): void {
this.lovelace!.setEditMode(false); this.lovelace!.setEditMode(false);
this._undoRedoController.reset();
} }
private async _editDashboard() { private async _editDashboard() {
@@ -1207,6 +1272,36 @@ class HUIRoot extends LitElement {
showShortcutsDialog(this); showShortcutsDialog(this);
} }
private async _applyUndoRedo(item: UndoStackItem) {
this._configChangedByUndo = true;
try {
await this.lovelace!.saveConfig(item.config);
} catch (err: any) {
this._configChangedByUndo = false;
showToast(this, {
message: this.hass.localize(
"ui.panel.lovelace.editor.undo_redo_failed_to_apply_changes",
{
error: err.message,
}
),
duration: 4000,
dismissable: true,
});
return;
}
this._navigateToView(item.location);
}
private _undo() {
this._undoRedoController.undo();
}
private _redo() {
this._undoRedoController.redo();
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyle, haStyle,

View File

@@ -1,13 +1,16 @@
import type { PropertyValues, TemplateResult } from "lit"; import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit"; import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import "../../components/ha-divider";
import "../../components/ha-list-item"; import "../../components/ha-list-item";
import "../../components/ha-select"; import "../../components/ha-select";
import "../../components/ha-settings-row"; import "../../components/ha-settings-row";
import { saveFrontendUserData } from "../../data/frontend";
import type { LovelaceDashboard } from "../../data/lovelace/dashboard"; import type { LovelaceDashboard } from "../../data/lovelace/dashboard";
import { fetchDashboards } from "../../data/lovelace/dashboard"; import { fetchDashboards } from "../../data/lovelace/dashboard";
import type { HomeAssistant } from "../../types"; import { getPanelTitle } from "../../data/panel";
import { saveFrontendUserData } from "../../data/frontend"; import type { HomeAssistant, PanelInfo } from "../../types";
import { PANEL_DASHBOARDS } from "../config/lovelace/dashboards/ha-config-lovelace-dashboards";
const USE_SYSTEM_VALUE = "___use_system___"; const USE_SYSTEM_VALUE = "___use_system___";
@@ -47,12 +50,24 @@ class HaPickDashboardRow extends LitElement {
<ha-list-item .value=${USE_SYSTEM_VALUE}> <ha-list-item .value=${USE_SYSTEM_VALUE}>
${this.hass.localize("ui.panel.profile.dashboard.system")} ${this.hass.localize("ui.panel.profile.dashboard.system")}
</ha-list-item> </ha-list-item>
<ha-divider></ha-divider>
<ha-list-item value="lovelace"> <ha-list-item value="lovelace">
${this.hass.localize("ui.panel.profile.dashboard.lovelace")} ${this.hass.localize("ui.panel.profile.dashboard.lovelace")}
</ha-list-item> </ha-list-item>
<ha-list-item value="home"> ${PANEL_DASHBOARDS.map((panel) => {
${this.hass.localize("ui.panel.profile.dashboard.home")} const panelInfo = this.hass.panels[panel] as
</ha-list-item> | PanelInfo
| undefined;
if (!panelInfo) {
return nothing;
}
return html`
<ha-list-item value=${panelInfo.url_path}>
${getPanelTitle(this.hass, panelInfo)}
</ha-list-item>
`;
})}
<ha-divider></ha-divider>
${this._dashboards.map((dashboard) => { ${this._dashboards.map((dashboard) => {
if (!this.hass.user!.is_admin && dashboard.require_admin) { if (!this.hass.user!.is_admin && dashboard.require_admin) {
return ""; return "";

View File

@@ -20,6 +20,7 @@ export const colorStyles = css`
--divider-color: rgba(0, 0, 0, 0.12); --divider-color: rgba(0, 0, 0, 0.12);
--outline-color: rgba(0, 0, 0, 0.12); --outline-color: rgba(0, 0, 0, 0.12);
--outline-hover-color: rgba(0, 0, 0, 0.24); --outline-hover-color: rgba(0, 0, 0, 0.24);
--shadow-color: rgba(0, 0, 0, 0.16);
/* rgb */ /* rgb */
--rgb-primary-color: 0, 154, 199; --rgb-primary-color: 0, 154, 199;
@@ -224,7 +225,7 @@ export const colorStyles = css`
--table-row-alternative-background-color: var(--secondary-background-color); --table-row-alternative-background-color: var(--secondary-background-color);
--data-table-background-color: var(--card-background-color); --data-table-background-color: var(--card-background-color);
--markdown-code-background-color: var(--primary-background-color); --markdown-code-background-color: var(--primary-background-color);
--bar-box-shadow: 0 2px 12px rgba(0, 0, 0, 0.16); --bar-box-shadow: 0 2px 12px var(--shadow-color);
/* https://github.com/material-components/material-web/blob/master/docs/theming.md */ /* https://github.com/material-components/material-web/blob/master/docs/theming.md */
--mdc-theme-primary: var(--primary-color); --mdc-theme-primary: var(--primary-color);
@@ -307,6 +308,8 @@ export const darkColorStyles = css`
--divider-color: rgba(225, 225, 225, 0.12); --divider-color: rgba(225, 225, 225, 0.12);
--outline-color: rgba(225, 225, 225, 0.12); --outline-color: rgba(225, 225, 225, 0.12);
--outline-hover-color: rgba(225, 225, 225, 0.24); --outline-hover-color: rgba(225, 225, 225, 0.24);
--shadow-color: rgba(0, 0, 0, 0.48);
--mdc-ripple-color: #aaaaaa; --mdc-ripple-color: #aaaaaa;
--mdc-linear-progress-buffer-color: rgba(255, 255, 255, 0.1); --mdc-linear-progress-buffer-color: rgba(255, 255, 255, 0.1);
@@ -350,7 +353,7 @@ export const darkColorStyles = css`
--ha-button-neutral-color: #d9dae0; --ha-button-neutral-color: #d9dae0;
--ha-button-neutral-light-color: #6a7081; --ha-button-neutral-light-color: #6a7081;
--bar-box-shadow: 0 2px 12px rgba(0, 0, 0, 0.48); --bar-box-shadow: 0 2px 12px var(--shadow-color);
} }
`; `;

View File

@@ -52,6 +52,8 @@ export const waColorStyles = css`
--wa-color-danger-on-normal: var(--ha-color-on-danger-normal); --wa-color-danger-on-normal: var(--ha-color-on-danger-normal);
--wa-color-danger-on-quiet: var(--ha-color-on-danger-quiet); --wa-color-danger-on-quiet: var(--ha-color-on-danger-quiet);
--wa-color-text-quiet: var(--ha-color-text-secondary);
--wa-color-text-normal: var(--ha-color-text-primary); --wa-color-text-normal: var(--ha-color-text-primary);
--wa-color-surface-default: var(--card-background-color); --wa-color-surface-default: var(--card-background-color);
--wa-color-surface-raised: var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)); --wa-color-surface-raised: var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff));
@@ -62,5 +64,7 @@ export const waColorStyles = css`
--wa-focus-ring-color: var(--ha-color-neutral-60); --wa-focus-ring-color: var(--ha-color-neutral-60);
--wa-shadow-l: 4px 8px 12px 0 rgba(0, 0, 0, 0.3); --wa-shadow-l: 4px 8px 12px 0 rgba(0, 0, 0, 0.3);
--wa-color-text-normal: var(--ha-color-text-primary);
} }
`; `;

View File

@@ -9,12 +9,16 @@ export const waMainStyles = css`
--wa-focus-ring-offset: 2px; --wa-focus-ring-offset: 2px;
--wa-focus-ring: var(--wa-focus-ring-style) var(--wa-focus-ring-width) var(--wa-focus-ring-color); --wa-focus-ring: var(--wa-focus-ring-style) var(--wa-focus-ring-width) var(--wa-focus-ring-color);
--wa-space-xs: var(--ha-space-2);
--wa-space-m: var(--ha-space-4);
--wa-space-l: var(--ha-space-6); --wa-space-l: var(--ha-space-6);
--wa-space-xl: var(--ha-space-8); --wa-space-xl: var(--ha-space-8);
--wa-form-control-padding-block: 0.75em; --wa-form-control-padding-block: 0.75em;
--wa-form-control-value-line-height: var(--ha-line-height-condensed);
--wa-font-weight-action: var(--ha-font-weight-medium); --wa-font-weight-action: var(--ha-font-weight-medium);
--wa-transition-normal: 150ms;
--wa-transition-fast: 75ms; --wa-transition-fast: 75ms;
--wa-transition-easing: ease; --wa-transition-easing: ease;
@@ -28,6 +32,7 @@ export const waMainStyles = css`
--wa-line-height-condensed: var(--ha-line-height-condensed); --wa-line-height-condensed: var(--ha-line-height-condensed);
--wa-font-size-m: var(--ha-font-size-m);
--wa-shadow-s: var(--ha-box-shadow-s); --wa-shadow-s: var(--ha-box-shadow-s);
--wa-shadow-m: var(--ha-box-shadow-m); --wa-shadow-m: var(--ha-box-shadow-m);
--wa-shadow-l: var(--ha-box-shadow-l); --wa-shadow-l: var(--ha-box-shadow-l);

View File

@@ -2217,7 +2217,9 @@
"sidebar_toggle": "Sidebar toggle", "sidebar_toggle": "Sidebar toggle",
"edit_sidebar": "Edit sidebar", "edit_sidebar": "Edit sidebar",
"edit_subtitle": "Synced on all devices", "edit_subtitle": "Synced on all devices",
"migrate_to_user_data": "This will change the sidebar on all the devices you are logged in to. To create a sidebar per device, you should use a different user for that device." "migrate_to_user_data": "This will change the sidebar on all the devices you are logged in to. To create a sidebar per device, you should use a different user for that device.",
"reset_to_defaults": "Reset to defaults",
"reset_confirmation": "Are you sure you want to reset the sidebar to its default configuration? This will restore the original order and visibility of all panels."
}, },
"panel": { "panel": {
"home": { "home": {
@@ -3508,6 +3510,7 @@
"edit": "Edit", "edit": "Edit",
"delete": "Delete", "delete": "Delete",
"add_dashboard": "Add dashboard", "add_dashboard": "Add dashboard",
"set_as_default": "Set as default",
"type": { "type": {
"user_created": "User created", "user_created": "User created",
"built_in": "Built-in" "built_in": "Built-in"
@@ -3516,7 +3519,7 @@
"confirm_delete_title": "Delete {dashboard_title}?", "confirm_delete_title": "Delete {dashboard_title}?",
"confirm_delete_text": "This dashboard will be permanently deleted.", "confirm_delete_text": "This dashboard will be permanently deleted.",
"cant_edit_yaml": "Dashboards created in YAML cannot be edited from the UI. Change them in configuration.yaml.", "cant_edit_yaml": "Dashboards created in YAML cannot be edited from the UI. Change them in configuration.yaml.",
"cant_edit_default": "The default dashboard, Overview, cannot be edited from the UI. You can hide it by setting another dashboard as default.", "cant_edit_lovelace": "The Overview dashboard title and icon cannot be changed. You can create a new dashboard to get more customization options.",
"detail": { "detail": {
"edit_dashboard": "Edit dashboard", "edit_dashboard": "Edit dashboard",
"new_dashboard": "Add new dashboard", "new_dashboard": "Add new dashboard",
@@ -3533,9 +3536,7 @@
"set_default": "Set as default", "set_default": "Set as default",
"remove_default": "Remove as default", "remove_default": "Remove as default",
"set_default_confirm_title": "Set as default dashboard?", "set_default_confirm_title": "Set as default dashboard?",
"set_default_confirm_text": "This will replace the current default dashboard. Users can still override their default dashboard in their profile settings.", "set_default_confirm_text": "This dashboard will be shown to all users when opening Home Assistant. Each user can change this in their profile."
"remove_default_confirm_title": "Remove default dashboard?",
"remove_default_confirm_text": "The default dashboard will be changed to Overview for every user. Users can still override their default dashboard in their profile settings."
} }
}, },
"resources": { "resources": {
@@ -4009,7 +4010,25 @@
"item_pasted": "{item} pasted", "item_pasted": "{item} pasted",
"ctrl": "Ctrl", "ctrl": "Ctrl",
"del": "Del", "del": "Del",
"targets": "Targets",
"select_target": "Select a target",
"home": "Home",
"unassigned": "Unassigned",
"blocks": "Blocks", "blocks": "Blocks",
"show_more": "Show more",
"unassigned_entities": "Unassigned entities",
"unassigned_devices": "Unassigned devices",
"empty_section_search": {
"block": "No blocks found for {term}",
"entity": "No entities found for {term}",
"device": "No devices found for {term}",
"area": "No areas or floors found for {term}",
"label": "No labels found for {term}"
},
"load_target_items_failed": "Failed to load target items for",
"other_areas": "Other areas",
"services": "Services",
"helpers": "Helpers",
"triggers": { "triggers": {
"name": "Triggers", "name": "Triggers",
"header": "When", "header": "When",
@@ -4017,7 +4036,10 @@
"learn_more": "Learn more about triggers", "learn_more": "Learn more about triggers",
"triggered": "Triggered", "triggered": "Triggered",
"add": "Add trigger", "add": "Add trigger",
"empty_search": "No triggers found for {term}", "empty_search": {
"global": "No triggers and targets found for {term}",
"item": "No triggers found for {term}"
},
"id": "Trigger ID", "id": "Trigger ID",
"optional": "Optional", "optional": "Optional",
"edit_id": "Edit ID", "edit_id": "Edit ID",
@@ -4038,6 +4060,7 @@
"copied_to_clipboard": "Trigger copied to clipboard", "copied_to_clipboard": "Trigger copied to clipboard",
"cut_to_clipboard": "Trigger cut to clipboard", "cut_to_clipboard": "Trigger cut to clipboard",
"select": "Select a trigger", "select": "Select a trigger",
"no_items_for_target": "No triggers available for",
"groups": { "groups": {
"device": { "device": {
"label": "Device" "label": "Device"
@@ -4279,7 +4302,10 @@
"description": "All conditions added here need to be satisfied for the automation to run. A condition can be satisfied or not at any given time, for example: ''If {user} is home''. You can use building blocks to create more complex conditions.", "description": "All conditions added here need to be satisfied for the automation to run. A condition can be satisfied or not at any given time, for example: ''If {user} is home''. You can use building blocks to create more complex conditions.",
"learn_more": "Learn more about conditions", "learn_more": "Learn more about conditions",
"add": "Add condition", "add": "Add condition",
"empty_search": "No conditions and blocks found for {term}", "empty_search": {
"global": "No conditions, blocks and targets found for {term}",
"item": "No conditions found for {term}"
},
"add_building_block": "Add building block", "add_building_block": "Add building block",
"test": "Test", "test": "Test",
"testing_error": "Condition did not pass", "testing_error": "Condition did not pass",
@@ -4302,6 +4328,7 @@
"copied_to_clipboard": "Condition copied to clipboard", "copied_to_clipboard": "Condition copied to clipboard",
"cut_to_clipboard": "Condition cut to clipboard", "cut_to_clipboard": "Condition cut to clipboard",
"select": "Select a condition", "select": "Select a condition",
"no_items_for_target": "No conditions available for",
"groups": { "groups": {
"device": { "device": {
"label": "Device" "label": "Device"
@@ -4447,7 +4474,10 @@
"description": "All actions added here will be performed in sequence when the automation runs. An action usually controls one of your areas, devices, or entities, for example: 'Turn on the lights'. You can use building blocks to create more complex sequences of actions.", "description": "All actions added here will be performed in sequence when the automation runs. An action usually controls one of your areas, devices, or entities, for example: 'Turn on the lights'. You can use building blocks to create more complex sequences of actions.",
"learn_more": "Learn more about actions", "learn_more": "Learn more about actions",
"add": "Add action", "add": "Add action",
"empty_search": "No actions and blocks found for {term}", "empty_search": {
"global": "No actions, blocks and targets found for {term}",
"item": "No actions found for {term}"
},
"add_building_block": "Add building block", "add_building_block": "Add building block",
"invalid_action": "Invalid action", "invalid_action": "Invalid action",
"run": "Run action", "run": "Run action",
@@ -4472,6 +4502,7 @@
"copied_to_clipboard": "Action copied to clipboard", "copied_to_clipboard": "Action copied to clipboard",
"cut_to_clipboard": "Action cut to clipboard", "cut_to_clipboard": "Action cut to clipboard",
"select": "Select an action", "select": "Select an action",
"no_items_for_target": "No actions available for",
"groups": { "groups": {
"device_id": { "device_id": {
"label": "Device" "label": "Device"
@@ -6780,6 +6811,7 @@
}, },
"analytics": { "analytics": {
"caption": "Analytics", "caption": "Analytics",
"header": "Home Assistant analytics",
"description": "Learn how to share data to improve Home Assistant", "description": "Learn how to share data to improve Home Assistant",
"preferences": { "preferences": {
"base": { "base": {
@@ -6797,10 +6829,21 @@
"diagnostics": { "diagnostics": {
"title": "Diagnostics", "title": "Diagnostics",
"description": "Share crash reports when unexpected errors occur." "description": "Share crash reports when unexpected errors occur."
},
"snapshots": {
"title": "Devices",
"description": "Generic information about your devices.",
"header": "Device analytics",
"info": "Anonymously share data about your devices to help build the Open Home Foundations device database. This free, open source resource helps users find useful information about smart home devices. Only device-specific details (like model or manufacturer) are shared — never personally identifying information (like the names you assign).",
"learn_more": "Learn more about the device database and how we process your data",
"alert": {
"title": "Important",
"content": "Only enable this option if you understand that your device information will be shared."
}
} }
}, },
"need_base_enabled": "You need to enable basic analytics for this option to be available", "need_base_enabled": "You need to enable basic analytics for this option to be available",
"learn_more": "How we process your data", "learn_more": "Learn how we process your data",
"intro": "Share anonymized information from your installation to help make Home Assistant better and help us convince manufacturers to add local control and privacy-focused features.", "intro": "Share anonymized information from your installation to help make Home Assistant better and help us convince manufacturers to add local control and privacy-focused features.",
"download_device_info": "Preview device analytics" "download_device_info": "Preview device analytics"
}, },
@@ -7146,6 +7189,7 @@
"energy_usage_graph": { "energy_usage_graph": {
"total_consumed": "Total consumed {num} kWh", "total_consumed": "Total consumed {num} kWh",
"total_returned": "Total returned {num} kWh", "total_returned": "Total returned {num} kWh",
"total_usage": "{num} kWh used",
"combined_from_grid": "Combined from grid", "combined_from_grid": "Combined from grid",
"consumed_solar": "Consumed solar", "consumed_solar": "Consumed solar",
"consumed_battery": "Consumed battery" "consumed_battery": "Consumed battery"
@@ -7290,6 +7334,7 @@
"editor": { "editor": {
"header": "Edit UI", "header": "Edit UI",
"yaml_unsupported": "The edit UI is not available when in YAML mode.", "yaml_unsupported": "The edit UI is not available when in YAML mode.",
"undo_redo_failed_to_apply_changes": "Unable to apply changes: {error}",
"menu": { "menu": {
"open": "Open dashboard menu", "open": "Open dashboard menu",
"raw_editor": "Raw configuration editor", "raw_editor": "Raw configuration editor",

View File

@@ -1940,9 +1940,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@home-assistant/webawesome@npm:3.0.0": "@home-assistant/webawesome@npm:3.0.0-ha.0":
version: 3.0.0 version: 3.0.0-ha.0
resolution: "@home-assistant/webawesome@npm:3.0.0" resolution: "@home-assistant/webawesome@npm:3.0.0-ha.0"
dependencies: dependencies:
"@ctrl/tinycolor": "npm:4.1.0" "@ctrl/tinycolor": "npm:4.1.0"
"@floating-ui/dom": "npm:^1.6.13" "@floating-ui/dom": "npm:^1.6.13"
@@ -1953,7 +1953,7 @@ __metadata:
lit: "npm:^3.2.1" lit: "npm:^3.2.1"
nanoid: "npm:^5.1.5" nanoid: "npm:^5.1.5"
qr-creator: "npm:^1.0.0" qr-creator: "npm:^1.0.0"
checksum: 10/03400894cfee8548fd5b1f5c56d31d253830e704b18ba69d36ce6b761d8b1bef2fb52cffba8d9b033033bb582f2f51a2d6444d82622f66d70150e2104fcb49e2 checksum: 10/2034d498d5b26bb0573ebc2c9aadd144604bb48c04becbae0c67b16857d8e5d6562626e795974362c3fc41e9b593a9005595d8b5ff434b1569b2d724af13043b
languageName: node languageName: node
linkType: hard linkType: hard
@@ -9226,7 +9226,7 @@ __metadata:
"@fullcalendar/list": "npm:6.1.19" "@fullcalendar/list": "npm:6.1.19"
"@fullcalendar/luxon3": "npm:6.1.19" "@fullcalendar/luxon3": "npm:6.1.19"
"@fullcalendar/timegrid": "npm:6.1.19" "@fullcalendar/timegrid": "npm:6.1.19"
"@home-assistant/webawesome": "npm:3.0.0" "@home-assistant/webawesome": "npm:3.0.0-ha.0"
"@lezer/highlight": "npm:1.2.3" "@lezer/highlight": "npm:1.2.3"
"@lit-labs/motion": "npm:1.0.9" "@lit-labs/motion": "npm:1.0.9"
"@lit-labs/observers": "npm:2.0.6" "@lit-labs/observers": "npm:2.0.6"
@@ -9332,7 +9332,7 @@ __metadata:
gulp-rename: "npm:2.1.0" gulp-rename: "npm:2.1.0"
gulp-zopfli-green: "npm:6.0.2" gulp-zopfli-green: "npm:6.0.2"
hls.js: "npm:1.6.14" hls.js: "npm:1.6.14"
home-assistant-js-websocket: "npm:9.5.0" home-assistant-js-websocket: "npm:9.6.0"
html-minifier-terser: "npm:7.2.0" html-minifier-terser: "npm:7.2.0"
husky: "npm:9.1.7" husky: "npm:9.1.7"
idb-keyval: "npm:6.2.2" idb-keyval: "npm:6.2.2"
@@ -9393,10 +9393,10 @@ __metadata:
languageName: unknown languageName: unknown
linkType: soft linkType: soft
"home-assistant-js-websocket@npm:9.5.0": "home-assistant-js-websocket@npm:9.6.0":
version: 9.5.0 version: 9.6.0
resolution: "home-assistant-js-websocket@npm:9.5.0" resolution: "home-assistant-js-websocket@npm:9.6.0"
checksum: 10/42f991b3b85aa61be28984f099a001ac083fb3da54b2777283d0c97976c564a303d8d4ba467e1b8e29cbc33151cd6eef64c1a7d3392d62bbb9cbb27aa7ca9942 checksum: 10/0eded7864632b5e19e92289ffac0e24308b1e8f425e292ae87ed21450852f7705db521e202614b1d5bbdb7948633143dce2524ed548db0c38486b40ed1ffa474
languageName: node languageName: node
linkType: hard linkType: hard