Compare commits

..

10 Commits

107 changed files with 1808 additions and 5388 deletions

2
.nvmrc
View File

@@ -1 +1 @@
24.14.0
24.13.1

View File

@@ -8,6 +8,7 @@ import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockFloorRegistry } from "../../../../demo/src/stubs/floor_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-formfield";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
@@ -16,7 +17,10 @@ import type { BlueprintInput } from "../../../../src/data/blueprint";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
import { showDialog } from "../../../../src/dialogs/make-dialog-manager";
import {
showDialog,
type ShowDialogParams,
} from "../../../../src/dialogs/make-dialog-manager";
import { getEntity } from "../../../../src/fake_data/entity";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { ProvideHassElement } from "../../../../src/mixins/provide-hass-lit-mixin";
@@ -611,14 +615,15 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
};
};
private _dialogManager = (e) => {
const { dialogTag, dialogImport, dialogParams, addHistory } = e.detail;
private _dialogManager = (e: HASSDomEvent<ShowDialogParams<unknown>>) => {
const { dialogTag, dialogImport, dialogParams, addHistory, parentElement } =
e.detail;
showDialog(
this,
this.shadowRoot!,
dialogTag,
dialogParams,
dialogImport,
parentElement,
addHistory
);
};

View File

@@ -118,7 +118,7 @@ class HaLandingPage extends LandingPageBaseElement {
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
makeDialogManager(this, this.shadowRoot!);
makeDialogManager(this);
if (window.innerWidth > 450) {
import("../../src/resources/particles");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -69,7 +69,7 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
await this.updateComplete;
requestAnimationFrame(() => {
if (this.hass && isIosApp(this.hass)) {
if (this.hass && isIosApp(this.hass.auth.external)) {
const element = this.renderRoot.querySelector("[autofocus]");
if (element !== null) {
if (!element.id) {

View File

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

View File

@@ -1,20 +1,20 @@
import { consume, type ContextType } from "@lit/context";
import { mdiInvertColorsOff, mdiPalette } from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { computeCssColor, THEME_COLORS } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeKeys } from "../common/translations/localize";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import { localizeContext } from "../data/context";
import type { ValueChangedEvent } from "../types";
import "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import type { PickerValueRenderer } from "./ha-picker-field";
@customElement("ha-color-picker")
export class HaColorPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public helper?: string;
@@ -34,12 +34,15 @@ export class HaColorPicker extends LitElement {
@property({ type: Boolean }) public required = false;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
render() {
const effectiveValue = this.value ?? this.defaultColor ?? "";
return html`
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required}
.hideClearIcon=${!this.value && !!this.defaultColor}
@@ -50,7 +53,7 @@ export class HaColorPicker extends LitElement {
.rowRenderer=${this._rowRenderer}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
.notFoundLabel=${this.hass.localize(
.notFoundLabel=${this.localize?.(
"ui.components.color-picker.no_colors_found"
)}
.getAdditionalItems=${this._getAdditionalItems}
@@ -78,7 +81,9 @@ export class HaColorPicker extends LitElement {
return [
{
id: searchString,
primary: this.hass.localize("ui.components.color-picker.custom_color"),
primary:
this.localize?.("ui.components.color-picker.custom_color") ||
"Custom color",
secondary: searchString,
},
];
@@ -101,16 +106,15 @@ export class HaColorPicker extends LitElement {
): PickerComboBoxItem[] => {
const items: PickerComboBoxItem[] = [];
const defaultSuffix = this.hass.localize(
"ui.components.color-picker.default"
);
const defaultSuffix =
this.localize?.("ui.components.color-picker.default") || "Default";
const addDefaultSuffix = (label: string, isDefault: boolean) =>
isDefault && defaultSuffix ? `${label} (${defaultSuffix})` : label;
if (includeNone) {
const noneLabel =
this.hass.localize("ui.components.color-picker.none") || "None";
this.localize?.("ui.components.color-picker.none") || "None";
items.push({
id: "none",
primary: addDefaultSuffix(noneLabel, defaultColor === "none"),
@@ -120,7 +124,7 @@ export class HaColorPicker extends LitElement {
if (includeState) {
const stateLabel =
this.hass.localize("ui.components.color-picker.state") || "State";
this.localize?.("ui.components.color-picker.state") || "State";
items.push({
id: "state",
primary: addDefaultSuffix(stateLabel, defaultColor === "state"),
@@ -130,7 +134,7 @@ export class HaColorPicker extends LitElement {
Array.from(THEME_COLORS).forEach((color) => {
const themeLabel =
this.hass.localize(
this.localize?.(
`ui.components.color-picker.colors.${color}` as LocalizeKeys
) || color;
items.push({
@@ -184,7 +188,7 @@ export class HaColorPicker extends LitElement {
return html`
<ha-svg-icon slot="start" .path=${mdiInvertColorsOff}></ha-svg-icon>
<span slot="headline">
${this.hass.localize("ui.components.color-picker.none")}
${this.localize?.("ui.components.color-picker.none") || "None"}
</span>
`;
}
@@ -192,7 +196,7 @@ export class HaColorPicker extends LitElement {
return html`
<ha-svg-icon slot="start" .path=${mdiPalette}></ha-svg-icon>
<span slot="headline">
${this.hass.localize("ui.components.color-picker.state")}
${this.localize?.("ui.components.color-picker.state") || "State"}
</span>
`;
}
@@ -200,7 +204,7 @@ export class HaColorPicker extends LitElement {
return html`
<span slot="start">${this._renderColorCircle(value)}</span>
<span slot="headline">
${this.hass.localize(
${this.localize?.(
`ui.components.color-picker.colors.${value}` as LocalizeKeys
) || value}
</span>

View File

@@ -1,5 +1,6 @@
import "@home-assistant/webawesome/dist/components/dialog/dialog";
import type WaDialog from "@home-assistant/webawesome/dist/components/dialog/dialog";
import { consume, type ContextType } from "@lit/context";
import { mdiClose } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import {
@@ -11,9 +12,9 @@ import {
} from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { authContext, localizeContext } from "../data/context";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import { isIosApp } from "../util/is_ios";
import "./ha-dialog-header";
import "./ha-icon-button";
@@ -77,8 +78,6 @@ type DialogHideEvent = CustomEvent<{ source?: Element }>;
*/
@customElement("ha-dialog")
export class HaDialog extends ScrollableFadeMixin(LitElement) {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: "aria-labelledby" })
public ariaLabelledBy?: string;
@@ -117,6 +116,14 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
@query(".body") public bodyContainer!: HTMLDivElement;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@state()
@consume({ context: authContext, subscribe: true })
private auth?: ContextType<typeof authContext>;
@state()
private _bodyScrolled = false;
@@ -162,7 +169,7 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
<slot name="headerNavigationIcon" slot="navigationIcon">
<ha-icon-button
data-dialog="close"
.label=${this.hass?.localize("ui.common.close") ?? "Close"}
.label=${this.localize?.("ui.common.close") ?? "Close"}
.path=${mdiClose}
></ha-icon-button>
</slot>
@@ -196,13 +203,13 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
await this.updateComplete;
requestAnimationFrame(() => {
if (this.hass && isIosApp(this.hass)) {
if (this.auth?.external && isIosApp(this.auth.external)) {
const element = this.querySelector("[autofocus]");
if (element !== null) {
if (!element.id) {
element.id = "ha-dialog-autofocus";
}
this.hass?.auth.external?.fireMessage({
this.auth.external.fireMessage({
type: "focus_element",
payload: {
element_id: element.id,
@@ -334,29 +341,29 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
@media all and (max-width: 450px), all and (max-height: 500px) {
:host([type="standard"]) {
--ha-dialog-border-radius: 0;
}
:host([type="standard"]) wa-dialog {
/* Make the container fill the whole screen width and not the safe width */
--full-width: var(--ha-dialog-width-full, 100vw);
--width: var(--full-width);
}
wa-dialog {
/* Make the container fill the whole screen width and not the safe width */
--full-width: var(--ha-dialog-width-full, 100vw);
--width: var(--full-width);
}
:host([type="standard"]) wa-dialog::part(dialog) {
/* Make the dialog fill the whole screen height and not the safe height */
min-height: var(--ha-dialog-min-height, 100vh);
min-height: var(--ha-dialog-min-height, 100dvh);
max-height: var(--ha-dialog-max-height, 100vh);
max-height: var(--ha-dialog-max-height, 100dvh);
margin-top: 0;
margin-bottom: 0;
/* Use safe area as padding instead of the container size */
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
/* Reset the transform to center the dialog */
transform: none;
wa-dialog::part(dialog) {
/* Make the dialog fill the whole screen height and not the safe height */
min-height: var(--ha-dialog-min-height, 100vh);
min-height: var(--ha-dialog-min-height, 100dvh);
max-height: var(--ha-dialog-max-height, 100vh);
max-height: var(--ha-dialog-max-height, 100dvh);
margin-top: 0;
margin-bottom: 0;
/* Use safe area as padding instead of the container size */
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
/* Reset the transform to center the dialog */
transform: none;
}
}
}

View File

@@ -1,5 +1,6 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume, type ContextType } from "@lit/context";
import { mdiPlaylistPlus } from "@mdi/js";
import {
css,
@@ -13,10 +14,9 @@ import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import { throttle } from "../common/util/throttle";
import { authContext } from "../data/context";
import { PickerMixin } from "../mixins/picker-mixin";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
import { isIosApp } from "../util/is_ios";
import "./ha-bottom-sheet";
import "./ha-button";
@@ -33,8 +33,6 @@ import "./ha-svg-icon";
@customElement("ha-generic-picker")
export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean, attribute: "allow-custom-value" })
public allowCustomValue;
@@ -113,6 +111,10 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@state()
@consume({ context: authContext, subscribe: true })
private auth?: ContextType<typeof authContext>;
@state() private _opened = false;
@state() private _pickerWrapperOpen = false;
@@ -142,10 +144,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
protected willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has("value")) {
this._setUnknownValue();
return;
}
if (changedProperties.has("hass")) {
this._throttleUnknownValue();
}
}
@@ -252,7 +250,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
return html`
<ha-picker-combo-box
id="combo-box"
.hass=${this.hass}
.allowCustomValue=${this.allowCustomValue}
.label=${this.searchLabel}
.value=${this.value}
@@ -291,13 +288,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
);
};
private _throttleUnknownValue = throttle(
this._setUnknownValue,
1000,
true,
false
);
private _renderHelper() {
const showError = this.invalid && this.errorMessage;
const showHelper = !showError && this.helper;
@@ -321,8 +311,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
this._comboBox?.setFieldValue(this._initialFieldValue);
this._initialFieldValue = undefined;
}
if (this.hass && isIosApp(this.hass)) {
this.hass.auth.external!.fireMessage({
if (this.auth?.external && isIosApp(this.auth.external)) {
this.auth.external.fireMessage({
type: "focus_element",
payload: {
element_id: "combo-box",

View File

@@ -5,7 +5,7 @@ import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { customIcons } from "../data/custom_icons";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { ValueChangedEvent } from "../types";
import "./ha-combo-box-item";
import "./ha-generic-picker";
import "./ha-icon";
@@ -88,8 +88,6 @@ const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
@customElement("ha-icon-picker")
export class HaIconPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property() public value?: string;
@property() public label?: string;
@@ -111,7 +109,6 @@ export class HaIconPicker extends LitElement {
protected render(): TemplateResult {
return html`
<ha-generic-picker
.hass=${this.hass}
allow-custom-value
.getItems=${this._getIconPickerItems}
.helper=${this.helper}

View File

@@ -1,371 +0,0 @@
import "@home-assistant/webawesome/dist/components/input/input";
import type WaInput from "@home-assistant/webawesome/dist/components/input/input";
import { mdiClose, mdiEye, mdiEyeOff, mdiInformationOutline } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { withViewTransition } from "../common/util/view-transition";
import "./ha-svg-icon";
import "./ha-tooltip";
@customElement("ha-input")
export class HaInput extends LitElement {
/** The type of input. */
@property()
public type:
| "date"
| "datetime-local"
| "email"
| "number"
| "password"
| "search"
| "tel"
| "text"
| "time"
| "url" = "text";
/** The current value of the input. */
@property()
public value: string | null = null;
/** The input's size. */
@property()
public size: "small" | "medium" | "large" = "medium";
/** The input's visual appearance. */
@property()
public appearance: "filled" | "outlined" | "filled-outlined" = "outlined";
/** Draws a pill-style input with rounded edges. */
@property({ type: Boolean })
public pill = false;
/** The input's label. */
@property()
public label = "";
/** The input's hint. */
@property()
public hint = "";
/** Adds a clear button when the input is not empty. */
@property({ type: Boolean, attribute: "with-clear" })
public withClear = false;
/** Placeholder text to show as a hint when the input is empty. */
@property()
public placeholder = "";
/** Makes the input readonly. */
@property({ type: Boolean })
public readonly = false;
/** Adds a button to toggle the password's visibility. */
@property({ type: Boolean, attribute: "password-toggle" })
public passwordToggle = false;
/** Determines whether or not the password is currently visible. */
@property({ type: Boolean, attribute: "password-visible" })
public passwordVisible = false;
/** Hides the browser's built-in increment/decrement spin buttons for number inputs. */
@property({ type: Boolean, attribute: "without-spin-buttons" })
public withoutSpinButtons = false;
/** Makes the input a required field. */
@property({ type: Boolean })
public required = false;
/** A regular expression pattern to validate input against. */
@property()
public pattern?: string;
/** The minimum length of input that will be considered valid. */
@property({ type: Number })
public minlength?: number;
/** The maximum length of input that will be considered valid. */
@property({ type: Number })
public maxlength?: number;
/** The input's minimum value. Only applies to date and number input types. */
@property()
public min?: number | string;
/** The input's maximum value. Only applies to date and number input types. */
@property()
public max?: number | string;
/** Specifies the granularity that the value must adhere to. */
@property()
public step?: number | "any";
/** Controls whether and how text input is automatically capitalized. */
@property()
// eslint-disable-next-line lit/no-native-attributes
public autocapitalize:
| "off"
| "none"
| "on"
| "sentences"
| "words"
| "characters"
| "" = "";
/** Indicates whether the browser's autocorrect feature is on or off. */
@property({ type: Boolean })
public autocorrect = false;
/** Specifies what permission the browser has to provide assistance in filling out form field values. */
@property()
public autocomplete?: string;
/** Indicates that the input should receive focus on page load. */
@property({ type: Boolean })
// eslint-disable-next-line lit/no-native-attributes
public autofocus = false;
/** Used to customize the label or icon of the Enter key on virtual keyboards. */
@property()
// eslint-disable-next-line lit/no-native-attributes
public enterkeyhint:
| "enter"
| "done"
| "go"
| "next"
| "previous"
| "search"
| "send"
| "" = "";
/** Enables spell checking on the input. */
@property({ type: Boolean })
// eslint-disable-next-line lit/no-native-attributes
public spellcheck = true;
/** Tells the browser what type of data will be entered by the user. */
@property()
// eslint-disable-next-line lit/no-native-attributes
public inputmode:
| "none"
| "text"
| "decimal"
| "numeric"
| "tel"
| "search"
| "email"
| "url"
| "" = "";
/** The name of the input, submitted as a name/value pair with form data. */
@property()
public name?: string;
/** Disables the form control. */
@property({ type: Boolean })
public disabled = false;
/** Custom validation message to show when the input is invalid. */
@property({ attribute: "validation-message" })
public validationMessage = "";
/** When true, validates the input on blur instead of on form submit. */
@property({ type: Boolean, attribute: "auto-validate" })
public autoValidate = false;
@state()
private _invalid = false;
@query("wa-input")
private _input!: WaInput;
static shadowRootOptions: ShadowRootInit = {
mode: "open",
delegatesFocus: true,
};
/** Selects all the text in the input. */
public select(): void {
this._input?.select();
}
/** Sets the start and end positions of the text selection (0-based). */
public setSelectionRange(
selectionStart: number,
selectionEnd: number,
selectionDirection?: "forward" | "backward" | "none"
): void {
this._input?.setSelectionRange(
selectionStart,
selectionEnd,
selectionDirection
);
}
/** Replaces a range of text with a new string. */
public setRangeText(
replacement: string,
start?: number,
end?: number,
selectMode?: "select" | "start" | "end" | "preserve"
): void {
this._input?.setRangeText(replacement, start, end, selectMode);
}
/** Displays the browser picker for an input element. */
public showPicker(): void {
this._input?.showPicker();
}
/** Increments the value of a numeric input type by the value of the step attribute. */
public stepUp(): void {
this._input?.stepUp();
}
/** Decrements the value of a numeric input type by the value of the step attribute. */
public stepDown(): void {
this._input?.stepDown();
}
protected render() {
return html`
<wa-input
.type=${this.type}
.value=${this.value}
.size=${this.size}
.appearance=${this.appearance}
.hint=${this._invalid ? this.validationMessage : ""}
.withClear=${this.withClear}
.placeholder=${this.placeholder}
.readonly=${this.readonly}
.passwordToggle=${this.passwordToggle}
.passwordVisible=${this.passwordVisible}
.withoutSpinButtons=${this.withoutSpinButtons}
.required=${this.required}
.pattern=${this.pattern}
.minlength=${this.minlength}
.maxlength=${this.maxlength}
.min=${this.min}
.max=${this.max}
.step=${this.step}
.autocapitalize=${this.autocapitalize || undefined}
.autocorrect=${this.autocorrect ? "on" : "off"}
.autocomplete=${this.autocomplete}
.autofocus=${this.autofocus}
.enterkeyhint=${this.enterkeyhint || undefined}
.spellcheck=${this.spellcheck}
.inputmode=${this.inputmode || undefined}
.name=${this.name}
.disabled=${this.disabled}
class=${this._invalid ? "invalid" : ""}
@input=${this._handleInput}
@change=${this._handleChange}
@blur=${this._handleBlur}
>
<div class="label" slot="label">
<span>
<slot name="label">${this.label}</slot>
</span>
${this.hint
? html`<ha-svg-icon
.path=${mdiInformationOutline}
id="hint"
></ha-svg-icon>
<ha-tooltip for="hint">${this.hint}</ha-tooltip> `
: nothing}
</div>
<slot name="start" slot="start"></slot>
<slot name="end" slot="end"></slot>
<slot name="clear-icon" slot="clear-icon">
<ha-svg-icon .path=${mdiClose}></ha-svg-icon>
</slot>
<slot name="show-password-icon" slot="show-password-icon">
<ha-svg-icon .path=${mdiEye}></ha-svg-icon>
</slot>
<slot name="hide-password-icon" slot="hide-password-icon">
<ha-svg-icon .path=${mdiEyeOff}></ha-svg-icon>
</slot>
</wa-input>
`;
}
private _handleInput() {
this.value = this._input?.value ?? null;
if (this._invalid) {
this._invalid = false;
}
}
private _handleChange() {
this.value = this._input?.value ?? null;
}
private _handleBlur() {
if (this.autoValidate) {
withViewTransition(() => {
this._invalid = !this._input.checkValidity();
});
}
}
static styles = css`
:host {
display: flex;
align-items: flex-start;
}
wa-input {
flex: 1;
min-width: 0;
}
wa-input::part(base):focus-within {
outline: none;
--wa-form-control-border-color: var(--ha-color-border-primary-normal);
}
wa-input.invalid {
--wa-form-control-border-color: var(--ha-color-border-danger-normal);
}
wa-input::part(label) {
margin-block-end: 2px;
}
.label {
height: 24px;
display: flex;
width: 100%;
align-items: center;
color: var(--ha-color-text-secondary);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
gap: var(--ha-space-1);
}
.label span {
line-height: 1;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.label ha-svg-icon {
color: var(--ha-color-on-disabled-normal);
--mdc-icon-size: 16px;
}
wa-input.invalid::part(hint) {
margin-block-start: var(--ha-space-1);
color: var(--ha-color-on-danger-quiet);
font-size: var(--ha-font-size-s);
margin-inline-start: var(--ha-space-3);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-input": HaInput;
}
}

View File

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

View File

@@ -1,5 +1,6 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume, type ContextType } from "@lit/context";
import { mdiClose, mdiMagnify, mdiMinusBoxOutline, mdiPlus } from "@mdi/js";
import Fuse from "fuse.js";
import { css, html, LitElement, nothing } from "lit";
@@ -14,6 +15,7 @@ import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import { localeContext, localizeContext } from "../data/context";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import {
multiTermSortedSearch,
@@ -21,7 +23,6 @@ import {
} from "../resources/fuseMultiTerm";
import { haStyleScrollbar } from "../resources/styles";
import { loadVirtualizer } from "../resources/virtualizer";
import type { HomeAssistant } from "../types";
import { isTouch } from "../util/is_touch";
import "./chips/ha-chip-set";
import "./chips/ha-filter-chip";
@@ -90,8 +91,6 @@ export type PickerComboBoxSearchFn<T extends PickerComboBoxItem> = (
@customElement("ha-picker-combo-box")
export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@property({ attribute: false }) public hass?: HomeAssistant;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@@ -162,6 +161,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@query("ha-textfield") private _searchFieldElement?: HaTextField;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@state()
@consume({ context: localeContext, subscribe: true })
private locale!: ContextType<typeof localeContext>;
@state() private _items: PickerComboBoxItem[] = [];
@state() private _selectedSection?: string;
@@ -215,9 +222,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
const searchLabel =
this.label ??
(this.allowCustomValue
? (this.hass?.localize("ui.components.combo-box.search_or_custom") ??
? (this.localize?.("ui.components.combo-box.search_or_custom") ??
"Search | Add custom value")
: (this.hass?.localize("ui.common.search") ?? "Search"));
: (this.localize?.("ui.common.search") ?? "Search"));
return html`<ha-textfield
.label=${searchLabel}
@@ -228,7 +235,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
<ha-icon-button
@click=${this._clearSearch}
slot="trailingIcon"
.label=${this.hass?.localize("ui.common.clear") || "Clear"}
.label=${this.localize?.("ui.common.clear") || "Clear"}
.path=${mdiClose}
></ha-icon-button>
</ha-textfield>
@@ -350,7 +357,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
return caseInsensitiveStringCompare(
sortLabelA,
sortLabelB,
this.hass?.locale.language ?? navigator.language
this.locale?.language ?? navigator.language
);
});
}
@@ -367,7 +374,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
id: this._search,
primary:
this.customValueLabel ??
this.hass?.localize("ui.components.combo-box.add_custom_item") ??
this.localize?.("ui.components.combo-box.add_custom_item") ??
"Add custom item",
secondary: `"${this._search}"`,
icon_path: mdiPlus,
@@ -401,10 +408,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
? typeof this.notFoundLabel === "function"
? this.notFoundLabel(this._search)
: this.notFoundLabel ||
this.hass?.localize("ui.components.combo-box.no_match") ||
this.localize?.("ui.components.combo-box.no_match") ||
"No matching items found"
: this.emptyLabel ||
this.hass?.localize("ui.components.combo-box.no_items") ||
this.localize?.("ui.components.combo-box.no_items") ||
"No items available"}</span
>
</ha-combo-box-item>
@@ -507,7 +514,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
id: searchString,
primary:
this.customValueLabel ??
this.hass?.localize("ui.components.combo-box.add_custom_item") ??
this.localize?.("ui.components.combo-box.add_custom_item") ??
"Add custom item",
secondary: `"${searchString}"`,
icon_path: mdiPlus,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,3 +34,5 @@ export const labelsContext = createContext<LabelRegistryEntry[]>("labels");
export const configEntriesContext =
createContext<ConfigEntry[]>("configEntries");
export const authContext = createContext<HomeAssistant["auth"]>("auth");

View File

@@ -1401,80 +1401,6 @@ export const calculateSolarConsumedGauge = (
return undefined;
};
/**
* Conversion factors from each flow rate unit to L/min.
* All HA-supported UnitOfVolumeFlowRate values are covered.
*
* m³/h → 1000/60 = 16.6667 L/min
* m³/min → 1000 L/min
* m³/s → 60000 L/min
* ft³/min→ 28.3168 L/min
* L/h → 1/60 L/min
* L/min → 1 L/min
* L/s → 60 L/min
* gal/h → 3.78541/60 L/min
* gal/min→ 3.78541 L/min
* gal/d → 3.78541/1440 L/min
* mL/s → 0.06 L/min
*/
/** Exact number of liters in one US gallon */
const LITERS_PER_GALLON = 3.785411784;
const FLOW_RATE_TO_LMIN: Record<string, number> = {
"m³/h": 1000 / 60,
"m³/min": 1000,
"m³/s": 60000,
"ft³/min": 28.316846592,
"L/h": 1 / 60,
"L/min": 1,
"L/s": 60,
"gal/h": LITERS_PER_GALLON / 60,
"gal/min": LITERS_PER_GALLON,
"gal/d": LITERS_PER_GALLON / 1440,
"mL/s": 60 / 1000,
};
/**
* Get current flow rate from an entity state, converted to L/min.
* @returns Flow rate in L/min, or undefined if unavailable/invalid.
*/
export const getFlowRateFromState = (
stateObj?: HassEntity
): number | undefined => {
if (!stateObj) {
return undefined;
}
const value = parseFloat(stateObj.state);
if (isNaN(value)) {
return undefined;
}
const unit = stateObj.attributes.unit_of_measurement;
const factor = unit ? FLOW_RATE_TO_LMIN[unit] : undefined;
if (factor === undefined) {
// Unknown unit return raw value as-is (best effort)
return value;
}
return value * factor;
};
/**
* Format a flow rate value (in L/min) to a human-readable string using
* the preferred unit system: metric → L/min, imperial → gal/min.
*/
export const formatFlowRateShort = (
hassLocale: HomeAssistant["locale"],
lengthUnitSystem: string,
litersPerMin: number
): string => {
const isMetric = lengthUnitSystem === "km";
if (isMetric) {
return `${formatNumber(litersPerMin, hassLocale, { maximumFractionDigits: 1 })} L/min`;
}
const galPerMin = litersPerMin / LITERS_PER_GALLON;
return `${formatNumber(galPerMin, hassLocale, { maximumFractionDigits: 1 })} gal/min`;
};
/**
* Get current power value from entity state, normalized to watts (W)
* @param stateObj - The entity state object to get power value from

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
import type { LitElement } from "lit";
import { fireEvent } from "../common/dom/fire_event";
import type { HaDialog } from "../components/ha-dialog";
import type { Constructor } from "../types";
import type { HassDialogNext } from "./make-dialog-manager";
export const DialogMixin = <
P = unknown,
T extends Constructor<LitElement> = Constructor<LitElement>,
>(
superClass: T
) =>
class extends superClass implements HassDialogNext<P> {
declare public params?: P;
private _closePromise?: Promise<boolean>;
private _closeResolve?: (value: boolean) => void;
public closeDialog(_historyState?: any): Promise<boolean> | boolean {
if (this._closePromise) {
return this._closePromise;
}
const dialogElement = this.shadowRoot?.querySelector(
"ha-dialog"
) as HaDialog | null;
if (dialogElement) {
this._closePromise = new Promise<boolean>((resolve) => {
this._closeResolve = resolve;
});
dialogElement.open = false;
}
return this._closePromise || true;
}
private _removeDialog = (ev) => {
ev.stopPropagation();
this._closeResolve?.(true);
this._closePromise = undefined;
this._closeResolve = undefined;
this.remove();
};
connectedCallback() {
super.connectedCallback();
this.addEventListener("closed", this._removeDialog, { once: true });
}
disconnectedCallback() {
fireEvent(this, "dialog-closed", { dialog: this.localName });
this.removeEventListener("closed", this._removeDialog);
super.disconnectedCallback();
}
};

View File

@@ -1,6 +1,7 @@
import type { LitElement } from "lit";
import { ancestorsWithProperty } from "../common/dom/ancestors-with-property";
import { deepActiveElement } from "../common/dom/deep-active-element";
import type { HASSDomEvent, ValidHassDomEvent } from "../common/dom/fire_event";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
import { nextRender } from "../common/util/render-status";
import type { ProvideHassElement } from "../mixins/provide-hass-lit-mixin";
@@ -19,18 +20,22 @@ declare global {
}
}
export interface HassDialog<
T = HASSDomEvents[ValidHassDomEvent],
> extends HTMLElement {
export interface HassDialog<T = unknown> extends HTMLElement {
showDialog(params: T);
closeDialog?: (historyState?: any) => boolean;
closeDialog?: (historyState?: any) => Promise<boolean> | boolean;
}
interface ShowDialogParams<T> {
export interface HassDialogNext<T = unknown> extends HTMLElement {
params?: T;
closeDialog?: (historyState?: any) => Promise<boolean> | boolean;
}
export interface ShowDialogParams<T> {
dialogTag: keyof HTMLElementTagNameMap;
dialogImport: () => Promise<unknown>;
dialogParams: T;
dialogParams?: T;
addHistory?: boolean;
parentElement?: LitElement;
}
export interface DialogClosedParams {
@@ -39,7 +44,6 @@ export interface DialogClosedParams {
export interface DialogState {
element: HTMLElement & ProvideHassElement;
root: ShadowRoot | HTMLElement;
dialogTag: string;
dialogParams: unknown;
dialogImport?: () => Promise<unknown>;
@@ -47,7 +51,7 @@ export interface DialogState {
}
interface LoadedDialogInfo {
element: Promise<HassDialog>;
element: Promise<HassDialogNext | HassDialog> | null;
closedFocusTargets?: Set<Element>;
}
@@ -57,12 +61,24 @@ const LOADED: LoadedDialogsDict = {};
const OPEN_DIALOG_STACK: DialogState[] = [];
export const FOCUS_TARGET = Symbol.for("HA focus target");
/**
* Shows a dialog element, lazy-loading it if needed, and optionally integrates
* dialog open/close behavior with browser history.
*
* @param element The host element that can provide `hass` and receives the dialog by default.
* @param dialogTag The custom element tag name of the dialog.
* @param dialogParams The params passed to the dialog via `showDialog()` or `params`.
* @param dialogImport Optional lazy import used when the dialog has not been loaded yet.
* @param parentElement Optional parent to append the dialog to instead of root element.
* @param addHistory Whether to add/update browser history so back navigation closes dialogs.
* @returns `true` if the dialog was shown (or could be shown), `false` if it could not be loaded.
*/
export const showDialog = async (
element: HTMLElement & ProvideHassElement,
root: ShadowRoot | HTMLElement,
element: LitElement & ProvideHassElement,
dialogTag: string,
dialogParams: unknown,
dialogImport?: () => Promise<unknown>,
parentElement?: LitElement,
addHistory = true
): Promise<boolean> => {
if (!(dialogTag in LOADED)) {
@@ -77,10 +93,18 @@ export const showDialog = async (
}
LOADED[dialogTag] = {
element: dialogImport().then(() => {
const dialogEl = document.createElement(dialogTag) as HassDialog;
element.provideHass(dialogEl);
const dialogEl = document.createElement(dialogTag) as
| HassDialogNext
| HassDialog;
if ("showDialog" in dialogEl) {
// provide hass for legacy persistent dialogs
element.provideHass(dialogEl);
}
dialogEl.addEventListener("dialog-closed", _handleClosed);
dialogEl.addEventListener("dialog-closed", _handleClosedFocus);
return dialogEl;
}),
};
@@ -96,10 +120,10 @@ export const showDialog = async (
});
return showDialog(
element,
root,
dialogTag,
dialogParams,
dialogImport,
parentElement,
addHistory
);
}
@@ -111,7 +135,6 @@ export const showDialog = async (
}
OPEN_DIALOG_STACK.push({
element,
root,
dialogTag,
dialogParams,
dialogImport,
@@ -134,12 +157,24 @@ export const showDialog = async (
FOCUS_TARGET
);
const dialogElement = await LOADED[dialogTag].element;
let dialogElement: HassDialogNext | HassDialog | null;
// Append it again so it's the last element in the root,
// so it's guaranteed to be on top of the other elements
root.appendChild(dialogElement);
dialogElement.showDialog(dialogParams);
if (LOADED[dialogTag] && LOADED[dialogTag].element === null) {
dialogElement = document.createElement(dialogTag) as HassDialogNext;
dialogElement.addEventListener("dialog-closed", _handleClosed);
dialogElement.addEventListener("dialog-closed", _handleClosedFocus);
LOADED[dialogTag].element = Promise.resolve(dialogElement);
} else {
dialogElement = await LOADED[dialogTag].element;
}
if ("showDialog" in dialogElement!) {
dialogElement.showDialog(dialogParams);
} else {
dialogElement!.params = dialogParams;
}
(parentElement || element).shadowRoot!.appendChild(dialogElement!);
return true;
};
@@ -152,7 +187,7 @@ export const closeDialog = async (
return true;
}
const dialogElement = await LOADED[dialogTag].element;
if (dialogElement.closeDialog) {
if (dialogElement && dialogElement.closeDialog) {
return dialogElement.closeDialog(historyState) !== false;
}
return true;
@@ -214,22 +249,34 @@ const _handleClosed = (ev: HASSDomEvent<DialogClosedParams>) => {
mainWindow.history.back();
}
}
// cleanup element
if (ev.currentTarget && "params" in ev.currentTarget) {
const dialogElement = ev.currentTarget as HassDialogNext;
dialogElement.removeEventListener("dialog-closed", _handleClosed);
dialogElement.removeEventListener("dialog-closed", _handleClosedFocus);
LOADED[ev.detail.dialog].element = null;
}
};
export const makeDialogManager = (
element: HTMLElement & ProvideHassElement,
root: ShadowRoot | HTMLElement
) => {
export const makeDialogManager = (element: LitElement & ProvideHassElement) => {
element.addEventListener(
"show-dialog",
(e: HASSDomEvent<ShowDialogParams<unknown>>) => {
const { dialogTag, dialogImport, dialogParams, addHistory } = e.detail;
const {
dialogTag,
dialogImport,
dialogParams,
addHistory,
parentElement,
} = e.detail;
showDialog(
element,
root,
dialogTag,
dialogParams,
dialogImport,
parentElement,
addHistory
);
}

View File

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

View File

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

View File

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

View File

@@ -146,7 +146,7 @@ export class QuickBar extends LitElement {
private _dialogOpened = async () => {
this._opened = true;
requestAnimationFrame(() => {
if (this.hass && isIosApp(this.hass)) {
if (this.hass && isIosApp(this.hass.auth.external)) {
this.hass.auth.external!.fireMessage({
type: "focus_element",
payload: {

View File

@@ -1,12 +1,13 @@
import { consume, type ContextType } from "@lit/context";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { customElement, state } from "lit/decorators";
import type { LocalizeKeys } from "../../common/translations/localize";
import "../../components/ha-alert";
import "../../components/ha-svg-icon";
import "../../components/ha-dialog";
import type { HomeAssistant } from "../../types";
import "../../components/ha-svg-icon";
import { localizeContext } from "../../data/context";
import { isMac } from "../../util/is_mac";
import { DialogMixin } from "../dialog-mixin";
interface Text {
textTranslationKey: LocalizeKeys;
@@ -165,24 +166,10 @@ const _SHORTCUTS: Section[] = [
];
@customElement("dialog-shortcuts")
class DialogShortcuts extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
public async showDialog(): Promise<void> {
this._open = true;
}
private _dialogClosed() {
this._open = false;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
public async closeDialog() {
this._open = false;
return true;
}
class DialogShortcuts extends DialogMixin(LitElement) {
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
private _renderShortcut(
shortcutKeys: ShortcutString[],
@@ -196,15 +183,13 @@ class DialogShortcuts extends LitElement {
>${shortcutKey === CTRL_CMD
? isMac
? "⌘"
: this.hass.localize("ui.dialogs.shortcuts.keys.ctrl")
: this.localize("ui.dialogs.shortcuts.keys.ctrl")
: typeof shortcutKey === "string"
? shortcutKey
: this.hass.localize(
shortcutKey.shortcutTranslationKey
)}</span
: this.localize(shortcutKey.shortcutTranslationKey)}</span
>`
)}
${this.hass.localize(descriptionKey)}
${this.localize(descriptionKey)}
</div>
`;
}
@@ -212,14 +197,13 @@ class DialogShortcuts extends LitElement {
protected render() {
return html`
<ha-dialog
.open=${this._open}
@closed=${this._dialogClosed}
.headerTitle=${this.hass.localize("ui.dialogs.shortcuts.title")}
open
.headerTitle=${this.localize("ui.dialogs.shortcuts.title")}
>
<div class="content">
${_SHORTCUTS.map(
(section) => html`
<h3>${this.hass.localize(section.titleTranslationKey)}</h3>
<h3>${this.localize(section.titleTranslationKey)}</h3>
<div class="items">
${section.items.map((item) => {
if ("shortcut" in item) {
@@ -229,7 +213,7 @@ class DialogShortcuts extends LitElement {
);
}
return html`<p>
${this.hass.localize((item as Text).textTranslationKey)}
${this.localize((item as Text).textTranslationKey)}
</p>`;
})}
</div>
@@ -238,9 +222,9 @@ class DialogShortcuts extends LitElement {
</div>
<ha-alert slot="footer">
${this.hass.localize("ui.dialogs.shortcuts.enable_shortcuts_hint", {
${this.localize("ui.dialogs.shortcuts.enable_shortcuts_hint", {
user_profile: html`<a href="/profile/general#shortcuts"
>${this.hass.localize(
>${this.localize(
"ui.dialogs.shortcuts.enable_shortcuts_hint_user_profile"
)}</a
>`,

View File

@@ -1,8 +1,8 @@
import type { LitElement } from "lit";
import { fireEvent } from "../../common/dom/fire_event";
export const showShortcutsDialog = (element: HTMLElement) =>
export const showShortcutsDialog = (element: LitElement) =>
fireEvent(element, "show-dialog", {
dialogTag: "dialog-shortcuts",
dialogImport: () => import("./dialog-shortcuts"),
dialogParams: {},
});

View File

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

View File

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

View File

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

View File

@@ -226,7 +226,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
) {
import("../resources/particles");
}
makeDialogManager(this, this.shadowRoot!);
makeDialogManager(this);
import("../components/ha-language-picker");
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,24 +1,29 @@
import { consume, type ContextType } from "@lit/context";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { customElement, state } from "lit/decorators";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-icon-picker";
import "../../../components/ha-button";
import "../../../components/ha-textfield";
import type {
CategoryRegistryEntry,
CategoryRegistryEntryMutableParams,
} from "../../../data/category_registry";
import { localizeContext } from "../../../data/context";
import { DialogMixin } from "../../../dialogs/dialog-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { CategoryRegistryDetailDialogParams } from "./show-dialog-category-registry-detail";
@customElement("dialog-category-registry-detail")
class DialogCategoryDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
class DialogCategoryDetail extends DialogMixin<CategoryRegistryDetailDialogParams>(
LitElement
) {
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@state() private _name!: string;
@@ -26,53 +31,32 @@ class DialogCategoryDetail extends LitElement {
@state() private _error?: string;
@state() private _params?: CategoryRegistryDetailDialogParams;
@state() private _submitting?: boolean;
@state() private _open = false;
public async showDialog(
params: CategoryRegistryDetailDialogParams
): Promise<void> {
this._params = params;
this._error = undefined;
this._open = true;
if (this._params.entry) {
this._name = this._params.entry.name || "";
this._icon = this._params.entry.icon || null;
public connectedCallback(): void {
super.connectedCallback();
if (this.params?.entry) {
this._name = this.params.entry.name || "";
this._icon = this.params.entry.icon || null;
} else {
this._name = this._params.suggestedName || "";
this._name = this.params?.suggestedName || "";
this._icon = null;
}
await this.updateComplete;
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._error = "";
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
if (!this.params) {
return nothing;
}
const entry = this._params.entry;
const entry = this.params.entry;
const nameInvalid = !this._isNameValid();
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
open
header-title=${entry
? this.hass.localize("ui.panel.config.category.editor.edit")
: this.hass.localize("ui.panel.config.category.editor.create")}
? this.localize("ui.panel.config.category.editor.edit")
: this.localize("ui.panel.config.category.editor.create")}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
@@ -81,8 +65,8 @@ class DialogCategoryDetail extends LitElement {
<ha-textfield
.value=${this._name}
@input=${this._nameChanged}
.label=${this.hass.localize("ui.panel.config.category.editor.name")}
.validationMessage=${this.hass.localize(
.label=${this.localize("ui.panel.config.category.editor.name")}
.validationMessage=${this.localize(
"ui.panel.config.category.editor.required_error_msg"
)}
required
@@ -90,10 +74,9 @@ class DialogCategoryDetail extends LitElement {
></ha-textfield>
<ha-icon-picker
.hass=${this.hass}
.value=${this._icon}
.value=${this._icon ?? undefined}
@value-changed=${this._iconChanged}
.label=${this.hass.localize("ui.panel.config.category.editor.icon")}
.label=${this.localize("ui.panel.config.category.editor.icon")}
></ha-icon-picker>
</div>
<ha-dialog-footer slot="footer">
@@ -102,7 +85,7 @@ class DialogCategoryDetail extends LitElement {
appearance="plain"
@click=${this.closeDialog}
>
${this.hass.localize("ui.common.cancel")}
${this.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@@ -110,8 +93,8 @@ class DialogCategoryDetail extends LitElement {
.disabled=${nameInvalid || !!this._submitting}
>
${entry
? this.hass.localize("ui.common.save")
: this.hass.localize("ui.common.add")}
? this.localize("ui.common.save")
: this.localize("ui.common.add")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
@@ -133,7 +116,7 @@ class DialogCategoryDetail extends LitElement {
}
private async _updateEntry() {
const create = !this._params!.entry;
const create = !this.params!.entry;
this._submitting = true;
let newValue: CategoryRegistryEntry | undefined;
try {
@@ -142,15 +125,15 @@ class DialogCategoryDetail extends LitElement {
icon: this._icon || (create ? undefined : null),
};
if (create) {
newValue = await this._params!.createEntry!(values);
newValue = await this.params!.createEntry!(values);
} else {
newValue = await this._params!.updateEntry!(values);
newValue = await this.params!.updateEntry!(values);
}
this.closeDialog();
} catch (err: any) {
this._error =
err.message ||
this.hass.localize("ui.panel.config.category.editor.unknown_error");
this.localize("ui.panel.config.category.editor.unknown_error");
} finally {
this._submitting = false;
}

View File

@@ -8,7 +8,7 @@ import "../../../../components/ha-duration-input";
import type { HaDurationData } from "../../../../components/ha-duration-input";
import "../../../../components/ha-formfield";
import "../../../../components/ha-icon-picker";
import "../../../../components/ha-input";
import "../../../../components/ha-textfield";
import type { ForDict } from "../../../../data/automation";
import type { DurationDict, Timer } from "../../../../data/timer";
import { haStyle } from "../../../../resources/styles";
@@ -66,21 +66,21 @@ class HaTimerForm extends LitElement {
return html`
<div class="form">
<ha-input
<ha-textfield
.value=${this._name}
.configValue=${"name"}
@input=${this._valueChanged}
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.name"
)}
auto-validate
autoValidate
required
.validationMessage=${this.hass!.localize(
"ui.dialogs.helper_settings.required_error_msg"
)}
dialogInitialFocus
.disabled=${this.disabled}
></ha-input>
></ha-textfield>
<ha-icon-picker
.hass=${this.hass}
.value=${this._icon}

View File

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

View File

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

View File

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

View File

@@ -20,9 +20,9 @@ import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon";
import type { ConfigEntry } from "../../../../../data/config_entries";
import { getConfigEntries } from "../../../../../data/config_entries";
import type { HomeAssistant } from "../../../../../types";
import "../../../../../layouts/hass-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
const THREAD_ICON =
"m 17.126982,8.0730792 c 0,-0.7297242 -0.593746,-1.32357 -1.323637,-1.32357 -0.729454,0 -1.323199,0.5938458 -1.323199,1.32357 v 1.3234242 l 1.323199,1.458e-4 c 0.729891,0 1.323637,-0.5937006 1.323637,-1.32357 z M 11.999709,0 C 5.3829818,0 0,5.3838955 0,12.001455 0,18.574352 5.3105455,23.927406 11.865164,24 V 12.012075 l -3.9275642,-2.91e-4 c -1.1669814,0 -2.1169453,0.949979 -2.1169453,2.118323 0,1.16718 0.9499639,2.116868 2.1169453,2.116868 v 2.615717 c -2.6093089,0 -4.732218,-2.12327 -4.732218,-4.732585 0,-2.61048 2.1229091,-4.7343308 4.732218,-4.7343308 l 3.9275642,5.82e-4 v -1.323279 c 0,-2.172296 1.766691,-3.9395777 3.938181,-3.9395777 2.171928,0 3.9392,1.7672817 3.9392,3.9395777 0,2.1721498 -1.767272,3.9395768 -3.9392,3.9395768 l -1.323199,-1.45e-4 V 23.744102 C 19.911127,22.597726 24,17.768833 24,12.001455 24,5.3838955 18.616727,0 11.999709,0 Z";
@@ -312,8 +312,7 @@ export class MatterConfigDashboard extends LitElement {
}
.container {
padding: var(--ha-space-2) var(--ha-space-4)
calc(var(--ha-space-16) + var(--safe-area-inset-bottom, 0px));
padding: var(--ha-space-2) var(--ha-space-4) var(--ha-space-4);
}
a[slot="fab"] {

View File

@@ -224,6 +224,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
slot="graphic"
.src=${brandsUrl({
domain: router.brand,
brand: true,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,29 @@
import { consume, type ContextType } from "@lit/context";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { customElement, state } from "lit/decorators";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-color-picker";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-icon-picker";
import "../../../components/ha-switch";
import "../../../components/ha-dialog";
import "../../../components/ha-textarea";
import "../../../components/ha-textfield";
import { localizeContext } from "../../../data/context";
import type { LabelRegistryEntryMutableParams } from "../../../data/label/label_registry";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { DialogMixin } from "../../../dialogs/dialog-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { LabelDetailDialogParams } from "./show-dialog-label-detail";
@customElement("dialog-label-detail")
class DialogLabelDetail
extends LitElement
implements HassDialog<LabelDetailDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
class DialogLabelDetail extends DialogMixin<LabelDetailDialogParams>(
LitElement
) {
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@state() private _name!: string;
@@ -34,53 +35,35 @@ class DialogLabelDetail
@state() private _error?: string;
@state() private _params?: LabelDetailDialogParams;
@state() private _submitting = false;
@state() private _open = false;
public showDialog(params: LabelDetailDialogParams): void {
this._params = params;
this._error = undefined;
if (this._params.entry) {
this._name = this._params.entry.name || "";
this._icon = this._params.entry.icon || "";
this._color = this._params.entry.color || "";
this._description = this._params.entry.description || "";
public connectedCallback(): void {
super.connectedCallback();
if (this.params?.entry) {
this._name = this.params.entry.name || "";
this._icon = this.params.entry.icon || "";
this._color = this.params.entry.color || "";
this._description = this.params.entry.description || "";
} else {
this._name = this._params.suggestedName || "";
this._name = this.params?.suggestedName || "";
this._icon = "";
this._color = "";
this._description = "";
}
this._open = true;
}
public closeDialog() {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
if (!this.params) {
return nothing;
}
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this._params.entry
? this._params.entry.name || this._params.entry.label_id
: this.hass!.localize("ui.dialogs.label-detail.new_label")}
open
header-title=${this.params.entry
? this.params.entry.name || this.params.entry.label_id
: this.localize("ui.dialogs.label-detail.new_label")}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<div>
${this._error
@@ -92,39 +75,35 @@ class DialogLabelDetail
.value=${this._name}
.configValue=${"name"}
@input=${this._input}
.label=${this.hass!.localize("ui.dialogs.label-detail.name")}
.validationMessage=${this.hass!.localize(
.label=${this.localize("ui.dialogs.label-detail.name")}
.validationMessage=${this.localize(
"ui.dialogs.label-detail.required_error_msg"
)}
required
></ha-textfield>
<ha-icon-picker
.value=${this._icon}
.hass=${this.hass}
.configValue=${"icon"}
@value-changed=${this._valueChanged}
.label=${this.hass!.localize("ui.dialogs.label-detail.icon")}
.label=${this.localize("ui.dialogs.label-detail.icon")}
></ha-icon-picker>
<ha-color-picker
.value=${this._color}
.configValue=${"color"}
.hass=${this.hass}
@value-changed=${this._valueChanged}
.label=${this.hass!.localize("ui.dialogs.label-detail.color")}
.label=${this.localize("ui.dialogs.label-detail.color")}
></ha-color-picker>
<ha-textarea
.value=${this._description}
.configValue=${"description"}
@input=${this._input}
.label=${this.hass!.localize(
"ui.dialogs.label-detail.description"
)}
.label=${this.localize("ui.dialogs.label-detail.description")}
></ha-textarea>
</div>
</div>
<ha-dialog-footer slot="footer">
${this._params.entry && this._params.removeEntry
${this.params.entry && this.params.removeEntry
? html`
<ha-button
slot="secondaryAction"
@@ -133,7 +112,7 @@ class DialogLabelDetail
@click=${this._deleteEntry}
.disabled=${this._submitting}
>
${this.hass!.localize("ui.common.delete")}
${this.localize("ui.common.delete")}
</ha-button>
`
: html`
@@ -142,7 +121,7 @@ class DialogLabelDetail
slot="secondaryAction"
@click=${this.closeDialog}
>
${this.hass.localize("ui.common.cancel")}
${this.localize("ui.common.cancel")}
</ha-button>
`}
<ha-button
@@ -150,9 +129,9 @@ class DialogLabelDetail
@click=${this._updateEntry}
.disabled=${this._submitting || !this._name}
>
${this._params.entry
? this.hass!.localize("ui.common.update")
: this.hass!.localize("ui.common.create")}
${this.params.entry
? this.localize("ui.common.update")
: this.localize("ui.common.create")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
@@ -184,10 +163,10 @@ class DialogLabelDetail
color: this._color.trim() || null,
description: this._description.trim() || null,
};
if (this._params!.entry) {
await this._params!.updateEntry!(values);
if (this.params!.entry) {
await this.params!.updateEntry!(values);
} else {
await this._params!.createEntry!(values);
await this.params!.createEntry!(values);
}
this.closeDialog();
} catch (err: any) {
@@ -200,8 +179,8 @@ class DialogLabelDetail
private async _deleteEntry() {
this._submitting = true;
try {
if (await this._params!.removeEntry!()) {
this._params = undefined;
if (await this.params!.removeEntry!()) {
this.params = undefined;
}
} finally {
this._submitting = false;

View File

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

View File

@@ -344,7 +344,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
const item: DataTableItem = {
icon: getPanelIcon(panelInfo),
title: getPanelTitle(this.hass, panelInfo) || panelInfo.url_path,
show_in_sidebar: panelInfo.show_in_sidebar || false,
show_in_sidebar: panelInfo.title != null,
mode: "storage",
url_path: panelInfo.url_path,
filename: "",
@@ -487,7 +487,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
title: getPanelTitle(this.hass, panelInfo) || panelInfo.url_path,
icon: getPanelIcon(panelInfo),
requireAdmin: panelInfo.require_admin || false,
showInSidebar: panelInfo.show_in_sidebar || false,
showInSidebar: panelInfo.title != null,
isDefault: panelInfo.url_path === defaultPanel,
updatePanel: async (values) => {
await updatePanel(this.hass!, panelInfo.url_path, values);

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,12 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { getEnergyDataCollection } from "../../../data/energy";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy";
import { shouldShowFloorsAndAreas } from "./show-floors-and-areas";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import {
LARGE_SCREEN_CONDITION,
SMALL_SCREEN_CONDITION,
} from "../../lovelace/strategies/helpers/screen-conditions";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
@customElement("power-view-strategy")
export class PowerViewStrategy extends ReactiveElement {
@@ -19,6 +14,11 @@ export class PowerViewStrategy extends ReactiveElement {
_config: LovelaceStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const view: LovelaceViewConfig = {
type: "sections",
sections: [{ type: "grid", cards: [] }],
};
const collectionKey =
_config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY;
@@ -39,54 +39,16 @@ export class PowerViewStrategy extends ReactiveElement {
const hasPowerDevices = prefs?.device_consumption.some(
(device) => device.stat_rate
);
const hasWaterDevices = prefs?.device_consumption_water.some(
(device) => device.stat_rate
);
const hasWaterSources = prefs?.energy_sources.some(
(source) => source.type === "water" && source.stat_rate
);
const hasGasSources = prefs?.energy_sources.some(
(source) => source.type === "gas" && source.stat_rate
);
const tileSection: LovelaceSectionConfig = {
type: "grid",
cards: [],
column_span: 2,
};
const chartsSection: LovelaceSectionConfig = {
type: "grid",
cards: [],
column_span: 2,
};
const tiles: LovelaceCardConfig[] = [];
const view: LovelaceViewConfig = {
type: "sections",
sections: [tileSection, chartsSection],
max_columns: 2,
};
// No sources configured
if (
!prefs ||
(!hasPowerSources &&
!hasPowerDevices &&
!hasWaterDevices &&
!hasWaterSources &&
!hasGasSources)
) {
// No power sources configured
if (!prefs || (!hasPowerSources && !hasPowerDevices)) {
return view;
}
if (hasPowerSources) {
const card = {
type: "power-total",
collection_key: collectionKey,
};
tiles.push(card);
const section = view.sections![0] as LovelaceSectionConfig;
chartsSection.cards!.push({
if (hasPowerSources) {
section.cards!.push({
title: hass.localize("ui.panel.energy.cards.power_sources_graph_title"),
type: "power-sources-graph",
collection_key: collectionKey,
@@ -96,29 +58,13 @@ export class PowerViewStrategy extends ReactiveElement {
});
}
if (hasGasSources) {
const card = {
type: "gas-total",
collection_key: collectionKey,
};
tiles.push({ ...card });
}
if (hasWaterSources) {
const card = {
type: "water-total",
collection_key: collectionKey,
};
tiles.push({ ...card });
}
if (hasPowerDevices) {
const showFloorsAndAreas = shouldShowFloorsAndAreas(
prefs.device_consumption,
hass,
(d) => d.stat_rate
);
chartsSection.cards!.push({
section.cards!.push({
title: hass.localize("ui.panel.energy.cards.power_sankey_title"),
type: "power-sankey",
collection_key: collectionKey,
@@ -130,41 +76,6 @@ export class PowerViewStrategy extends ReactiveElement {
});
}
if (hasWaterDevices) {
const showFloorsAndAreas = shouldShowFloorsAndAreas(
prefs.device_consumption_water,
hass,
(d) => d.stat_rate
);
chartsSection.cards!.push({
title: hass.localize("ui.panel.energy.cards.water_flow_sankey_title"),
type: "water-flow-sankey",
collection_key: collectionKey,
group_by_floor: showFloorsAndAreas,
group_by_area: showFloorsAndAreas,
grid_options: {
columns: 36,
},
});
}
tiles.forEach((card) => {
tileSection.cards!.push({
...card,
grid_options: { columns: 24 / tiles.length },
});
});
if (tiles.length > 2) {
// On small screens with 3 tiles, show them in 1 column
tileSection.visibility = [LARGE_SCREEN_CONDITION];
view.sections!.unshift({
type: "grid",
cards: tiles,
visibility: [SMALL_SCREEN_CONDITION],
});
}
return view;
}
}

View File

@@ -109,35 +109,18 @@ const processAreasForLight = (
// Toggle group card for desktop
cards.push({
type: "toggle-group",
title: hass.localize("ui.panel.lovelace.strategy.light.all_lights"),
color: "amber",
entities: areaLights,
visibility: [LARGE_SCREEN_CONDITION],
grid_options: {
columns: 6,
rows: 1,
min_columns: 6,
},
} as ToggleGroupCardConfig);
areaCards.forEach((card) => {
// Insert a blank card before every 3rd card to align the individual
// cards with the toggle group card on desktop
if (
areaCards.indexOf(card) % 3 === 0 &&
areaCards.indexOf(card) !== 0
) {
cards.push({
type: "vertical-stack",
cards: [],
visibility: [LARGE_SCREEN_CONDITION],
grid_options: {
columns: 6,
rows: 1,
},
});
}
cards.push(card);
});
cards.push(...areaCards);
}
}

View File

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

View File

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

View File

@@ -1,153 +0,0 @@
import { mdiFire } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import "../../../../components/tile/ha-tile-container";
import "../../../../components/tile/ha-tile-icon";
import "../../../../components/tile/ha-tile-info";
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
import {
formatFlowRateShort,
getEnergyDataCollection,
getFlowRateFromState,
} from "../../../../data/energy";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard, LovelaceGridOptions } from "../../types";
import { tileCardStyle } from "../tile/tile-card-style";
import type { GasTotalCardConfig } from "../types";
@customElement("hui-gas-total-card")
export class HuiGasTotalCard
extends SubscribeMixin(LitElement)
implements LovelaceCard
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: GasTotalCardConfig;
@state() private _data?: EnergyData;
private _entities = new Set<string>();
protected hassSubscribeRequiredHostProps = ["_config"];
public setConfig(config: GasTotalCardConfig): void {
this._config = config;
}
public hassSubscribe(): UnsubscribeFunc[] {
return [
getEnergyDataCollection(this.hass, {
key: this._config?.collection_key,
}).subscribe((data) => {
this._data = data;
}),
];
}
public getCardSize(): Promise<number> | number {
return 1;
}
getGridOptions(): LovelaceGridOptions {
return {
columns: 12,
min_columns: 6,
rows: 1,
min_rows: 1,
};
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
if (changedProps.has("_config") || changedProps.has("_data")) {
return true;
}
// Check if any of the tracked entity states have changed
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || !this._entities.size) {
return true;
}
// Only update if one of our tracked entities changed
for (const entityId of this._entities) {
if (oldHass.states[entityId] !== this.hass.states[entityId]) {
return true;
}
}
}
return false;
}
private _getCurrentFlowRate(entityId: string): number {
this._entities.add(entityId);
return getFlowRateFromState(this.hass.states[entityId]) ?? 0;
}
private _computeTotalFlowRate(prefs: EnergyPreferences): number {
this._entities.clear();
let totalFlow = 0;
prefs.energy_sources.forEach((source) => {
if (source.type === "gas" && source.stat_rate) {
const value = this._getCurrentFlowRate(source.stat_rate);
if (value > 0) totalFlow += value;
}
});
return Math.max(0, totalFlow);
}
protected render() {
if (!this._config || !this._data) {
return nothing;
}
const flowRate = this._computeTotalFlowRate(this._data.prefs);
const displayValue = formatFlowRateShort(
this.hass.locale,
this.hass.config.unit_system.length,
flowRate
);
const name =
this._config.title ||
this.hass.localize("ui.panel.lovelace.cards.energy.gas_total_title");
return html`
<ha-card>
<ha-tile-container .interactive=${false}>
<ha-tile-icon slot="icon" data-domain="sensor" data-state="active">
<ha-svg-icon slot="icon" .path=${mdiFire}></ha-svg-icon>
</ha-tile-icon>
<ha-tile-info slot="info">
<span slot="primary" class="primary">${name}</span>
<span slot="secondary" class="secondary">${displayValue}</span>
</ha-tile-info>
</ha-tile-container>
</ha-card>
`;
}
static styles = [
tileCardStyle,
css`
:host {
--tile-color: var(--energy-gas-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"hui-gas-total-card": HuiGasTotalCard;
}
}

View File

@@ -1,175 +0,0 @@
import { mdiHomeLightningBolt } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { formatNumber } from "../../../../common/number/format_number";
import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import "../../../../components/tile/ha-tile-container";
import "../../../../components/tile/ha-tile-icon";
import "../../../../components/tile/ha-tile-info";
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
import {
getEnergyDataCollection,
getPowerFromState,
} from "../../../../data/energy";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard, LovelaceGridOptions } from "../../types";
import { tileCardStyle } from "../tile/tile-card-style";
import type { PowerTotalCardConfig } from "../types";
@customElement("hui-power-total-card")
export class HuiPowerTotalCard
extends SubscribeMixin(LitElement)
implements LovelaceCard
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: PowerTotalCardConfig;
@state() private _data?: EnergyData;
private _entities = new Set<string>();
protected hassSubscribeRequiredHostProps = ["_config"];
public setConfig(config: PowerTotalCardConfig): void {
this._config = config;
}
public hassSubscribe(): UnsubscribeFunc[] {
return [
getEnergyDataCollection(this.hass, {
key: this._config?.collection_key,
}).subscribe((data) => {
this._data = data;
}),
];
}
public getCardSize(): Promise<number> | number {
return 1;
}
getGridOptions(): LovelaceGridOptions {
return {
columns: 12,
min_columns: 6,
rows: 1,
min_rows: 1,
};
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
if (changedProps.has("_config") || changedProps.has("_data")) {
return true;
}
// Check if any of the tracked entity states have changed
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || !this._entities.size) {
return true;
}
// Only update if one of our tracked entities changed
for (const entityId of this._entities) {
if (oldHass.states[entityId] !== this.hass.states[entityId]) {
return true;
}
}
}
return false;
}
private _getCurrentPower(entityId: string): number {
this._entities.add(entityId);
return getPowerFromState(this.hass.states[entityId]) ?? 0;
}
private _computeTotalPower(prefs: EnergyPreferences): number {
this._entities.clear();
let solar = 0;
let from_grid = 0;
let to_grid = 0;
let from_battery = 0;
let to_battery = 0;
prefs.energy_sources.forEach((source) => {
if (source.type === "solar" && source.stat_rate) {
const value = this._getCurrentPower(source.stat_rate);
if (value > 0) solar += value;
} else if (source.type === "grid" && source.stat_rate) {
const value = this._getCurrentPower(source.stat_rate);
if (value > 0) from_grid += value;
else if (value < 0) to_grid += Math.abs(value);
} else if (source.type === "battery" && source.stat_rate) {
const value = this._getCurrentPower(source.stat_rate);
if (value > 0) from_battery += value;
else if (value < 0) to_battery += Math.abs(value);
}
});
const used_total = from_grid + solar + from_battery - to_grid - to_battery;
return Math.max(0, used_total);
}
protected render() {
if (!this._config || !this._data) {
return nothing;
}
const power = this._computeTotalPower(this._data.prefs);
let displayValue = "";
if (power >= 1000) {
displayValue = `${formatNumber(power / 1000, this.hass.locale, {
maximumFractionDigits: 2,
})} kW`;
} else {
displayValue = `${formatNumber(power, this.hass.locale, {
maximumFractionDigits: 0,
})} W`;
}
const name =
this._config.title ||
this.hass.localize("ui.panel.lovelace.cards.energy.power_total_title");
return html`
<ha-card>
<ha-tile-container .interactive=${false}>
<ha-tile-icon slot="icon" data-domain="sensor" data-state="active">
<ha-svg-icon
slot="icon"
.path=${mdiHomeLightningBolt}
></ha-svg-icon>
</ha-tile-icon>
<ha-tile-info slot="info">
<span slot="primary" class="primary">${name}</span>
<span slot="secondary" class="secondary">${displayValue}</span>
</ha-tile-info>
</ha-tile-container>
</ha-card>
`;
}
static styles = [
tileCardStyle,
css`
:host {
--tile-color: var(--primary-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"hui-power-total-card": HuiPowerTotalCard;
}
}

View File

@@ -1,153 +0,0 @@
import { mdiWater } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import "../../../../components/tile/ha-tile-container";
import "../../../../components/tile/ha-tile-icon";
import "../../../../components/tile/ha-tile-info";
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
import {
formatFlowRateShort,
getEnergyDataCollection,
getFlowRateFromState,
} from "../../../../data/energy";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard, LovelaceGridOptions } from "../../types";
import { tileCardStyle } from "../tile/tile-card-style";
import type { WaterTotalCardConfig } from "../types";
@customElement("hui-water-total-card")
export class HuiWaterTotalCard
extends SubscribeMixin(LitElement)
implements LovelaceCard
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: WaterTotalCardConfig;
@state() private _data?: EnergyData;
private _entities = new Set<string>();
protected hassSubscribeRequiredHostProps = ["_config"];
public setConfig(config: WaterTotalCardConfig): void {
this._config = config;
}
public hassSubscribe(): UnsubscribeFunc[] {
return [
getEnergyDataCollection(this.hass, {
key: this._config?.collection_key,
}).subscribe((data) => {
this._data = data;
}),
];
}
public getCardSize(): Promise<number> | number {
return 1;
}
getGridOptions(): LovelaceGridOptions {
return {
columns: 12,
min_columns: 6,
rows: 1,
min_rows: 1,
};
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
if (changedProps.has("_config") || changedProps.has("_data")) {
return true;
}
// Check if any of the tracked entity states have changed
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || !this._entities.size) {
return true;
}
// Only update if one of our tracked entities changed
for (const entityId of this._entities) {
if (oldHass.states[entityId] !== this.hass.states[entityId]) {
return true;
}
}
}
return false;
}
private _getCurrentFlowRate(entityId: string): number {
this._entities.add(entityId);
return getFlowRateFromState(this.hass.states[entityId]) ?? 0;
}
private _computeTotalFlowRate(prefs: EnergyPreferences): number {
this._entities.clear();
let totalFlow = 0;
prefs.energy_sources.forEach((source) => {
if (source.type === "water" && source.stat_rate) {
const value = this._getCurrentFlowRate(source.stat_rate);
if (value > 0) totalFlow += value;
}
});
return Math.max(0, totalFlow);
}
protected render() {
if (!this._config || !this._data) {
return nothing;
}
const flowRate = this._computeTotalFlowRate(this._data.prefs);
const displayValue = formatFlowRateShort(
this.hass.locale,
this.hass.config.unit_system.length,
flowRate
);
const name =
this._config.title ||
this.hass.localize("ui.panel.lovelace.cards.energy.water_total_title");
return html`
<ha-card>
<ha-tile-container .interactive=${false}>
<ha-tile-icon slot="icon" data-domain="sensor" data-state="active">
<ha-svg-icon slot="icon" .path=${mdiWater}></ha-svg-icon>
</ha-tile-icon>
<ha-tile-info slot="info">
<span slot="primary" class="primary">${name}</span>
<span slot="secondary" class="secondary">${displayValue}</span>
</ha-tile-info>
</ha-tile-container>
</ha-card>
`;
}
static styles = [
tileCardStyle,
css`
:host {
--tile-color: var(--energy-water-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"hui-water-total-card": HuiWaterTotalCard;
}
}

View File

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

View File

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

View File

@@ -251,35 +251,12 @@ export interface WaterSankeyCardConfig extends EnergyCardBaseConfig {
group_by_area?: boolean;
}
export interface WaterFlowSankeyCardConfig extends EnergyCardBaseConfig {
type: "water-flow-sankey";
title?: string;
layout?: "vertical" | "horizontal" | "auto";
group_by_floor?: boolean;
group_by_area?: boolean;
}
export interface PowerSourcesGraphCardConfig extends EnergyCardBaseConfig {
type: "power-sources-graph";
title?: string;
show_legend?: boolean;
}
export interface PowerTotalCardConfig extends EnergyCardBaseConfig {
type: "power-total";
title?: string;
}
export interface WaterTotalCardConfig extends EnergyCardBaseConfig {
type: "water-total";
title?: string;
}
export interface GasTotalCardConfig extends EnergyCardBaseConfig {
type: "gas-total";
title?: string;
}
export interface PowerSankeyCardConfig extends EnergyCardBaseConfig {
type: "power-sankey";
title?: string;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +0,0 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceViewFooterConfig } from "../../../../data/lovelace/config/view";
export interface EditViewFooterDialogParams {
saveConfig: (config: LovelaceViewFooterConfig) => void;
config: LovelaceViewFooterConfig;
maxColumns: number;
}
export const showEditViewFooterDialog = (
element: HTMLElement,
dialogParams: EditViewFooterDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "hui-dialog-edit-view-footer",
dialogImport: () => import("./hui-dialog-edit-view-footer"),
dialogParams: dialogParams,
});
};

View File

@@ -239,15 +239,28 @@ export class LovelacePanel extends LitElement {
const newConfig = checkLovelaceConfig(generatedConfig) as LovelaceConfig;
// Regenerate if the config changed
// Ask to regenerate if the config changed
if (!deepEqual(newConfig, oldConfig)) {
this._regenerateStrategyConfig();
this._askRegenerateStrategyConfig();
}
};
private _strategyConfigChanged = (ev: CustomEvent) => {
ev.stopPropagation();
this._regenerateStrategyConfig();
this._askRegenerateStrategyConfig();
};
private _askRegenerateStrategyConfig = () => {
showToast(this, {
message: this.hass!.localize("ui.panel.lovelace.changed_toast.message"),
action: {
action: () => this._regenerateStrategyConfig(),
text: this.hass!.localize("ui.common.refresh"),
},
duration: -1,
id: "regenerate-strategy-config",
dismissable: false,
});
};
private async _regenerateStrategyConfig() {
@@ -300,14 +313,8 @@ export class LovelacePanel extends LitElement {
this._fetchConfigOnConnect = true;
return;
}
if (!this.lovelace?.editMode && this._panelState !== "yaml-editor") {
this._fetchConfig(false);
return;
}
showToast(this, {
message: this.hass!.localize(
"ui.panel.lovelace.externally_updated_toast.message"
),
message: this.hass!.localize("ui.panel.lovelace.changed_toast.message"),
action: {
action: () => this._fetchConfig(false),
text: this.hass!.localize("ui.common.refresh"),

View File

@@ -7,20 +7,21 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { array, assert, object, optional, string, type } from "superstruct";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-button";
import "../../components/ha-code-editor";
import type { HaCodeEditor } from "../../components/ha-code-editor";
import "../../components/ha-icon-button";
import "../../components/ha-top-app-bar-fixed";
import type { LovelaceRawConfig } from "../../data/lovelace/config/types";
import { isStrategyDashboard } from "../../data/lovelace/config/types";
import "../../components/ha-button";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showToast } from "../../util/toast";
import type { Lovelace } from "./types";
import "../../components/ha-top-app-bar-fixed";
import type { LovelaceRawConfig } from "../../data/lovelace/config/types";
import { isStrategyDashboard } from "../../data/lovelace/config/types";
const lovelaceStruct = type({
title: optional(string()),
@@ -112,7 +113,21 @@ class LovelaceFullConfigEditor extends LitElement {
oldLovelace.rawConfig !== this.lovelace.rawConfig &&
!deepEqual(oldLovelace.rawConfig, this.lovelace.rawConfig)
) {
this.yamlEditor.value = dump(this.lovelace!.rawConfig);
showToast(this, {
message: this.hass!.localize(
"ui.panel.lovelace.editor.raw_editor.lovelace_changed"
),
action: {
action: () => {
this.yamlEditor.value = dump(this.lovelace!.rawConfig);
},
text: this.hass!.localize(
"ui.panel.lovelace.editor.raw_editor.reload"
),
},
duration: -1,
dismissable: false,
});
}
}

View File

@@ -30,7 +30,6 @@ import {
import type { HuiSection } from "../sections/hui-section";
import type { Lovelace } from "../types";
import { generateDefaultSection } from "./default-section";
import "./hui-view-footer";
import "./hui-view-header";
import "./hui-view-sidebar";
@@ -309,12 +308,6 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
`
: nothing}
</div>
<hui-view-footer
.hass=${this.hass}
.lovelace=${this.lovelace}
.viewIndex=${this.index}
.config=${this._config?.footer}
></hui-view-footer>
<div class="imported-cards-section">
${editMode && this._config?.cards?.length
? html`
@@ -663,10 +656,6 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
padding-top: var(--row-gap);
}
hui-view-footer {
display: block;
}
.imported-cards {
--column-span: var(--column-count);
--row-span: 1;

View File

@@ -1,359 +0,0 @@
import { mdiPencil, mdiPlus } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import "../../../components/ha-ripple";
import "../../../components/ha-svg-icon";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type {
LovelaceViewConfig,
LovelaceViewFooterConfig,
} from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import type { HuiCard } from "../cards/hui-card";
import { computeCardGridSize } from "../common/compute-card-grid-size";
import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
import { replaceView } from "../editor/config-util";
import { showEditViewFooterDialog } from "../editor/view-footer/show-edit-view-footer-dialog";
import type { Lovelace } from "../types";
import { DEFAULT_MAX_COLUMNS } from "./hui-sections-view";
@customElement("hui-view-footer")
export class HuiViewFooter extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public lovelace!: Lovelace;
@property({ attribute: false }) public card?: HuiCard;
@property({ attribute: false }) public config?: LovelaceViewFooterConfig;
@property({ attribute: false }) public viewIndex!: number;
willUpdate(changedProperties: PropertyValues<typeof this>): void {
if (changedProperties.has("config")) {
if (this.config?.card) {
this.card = this._createCardElement(this.config.card);
} else {
this.card = undefined;
}
this._checkHidden();
return;
}
if (this.card) {
if (changedProperties.has("hass")) {
this.card.hass = this.hass;
}
if (changedProperties.has("lovelace")) {
this.card.preview = this.lovelace.editMode;
}
}
if (changedProperties.has("lovelace") || changedProperties.has("card")) {
this._checkHidden();
}
}
private _checkHidden() {
const hidden = !this.card && !this.lovelace?.editMode;
this.toggleAttribute("hidden", hidden);
this.toggleAttribute("sticky", Boolean(this.card));
}
private _createCardElement(cardConfig: LovelaceCardConfig) {
const element = document.createElement("hui-card");
element.hass = this.hass;
element.preview = this.lovelace.editMode;
element.layout = "grid";
element.config = cardConfig;
element.addEventListener("card-updated", (ev: Event) => {
ev.stopPropagation();
this.requestUpdate();
});
element.load();
return element;
}
private _addCard() {
showCreateCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: [this.viewIndex],
saveCard: (newCardConfig: LovelaceCardConfig) => {
this._saveFooterConfig({ ...this.config, card: newCardConfig });
},
});
}
private _deleteCard(ev) {
ev.stopPropagation();
const newConfig = { ...this.config };
delete newConfig.card;
this._saveFooterConfig(newConfig);
}
private _configure() {
const viewConfig = this.lovelace.config.views[
this.viewIndex
] as LovelaceViewConfig;
showEditViewFooterDialog(this, {
config: this.config || {},
maxColumns: viewConfig.max_columns || DEFAULT_MAX_COLUMNS,
saveConfig: (newConfig: LovelaceViewFooterConfig) => {
this._saveFooterConfig(newConfig);
},
});
}
private _editCard(ev) {
ev.stopPropagation();
const cardConfig = this.config?.card;
if (!cardConfig) return;
showEditCardDialog(this, {
cardConfig,
lovelaceConfig: this.lovelace.config,
saveCardConfig: (newCardConfig: LovelaceCardConfig) => {
this._saveFooterConfig({ ...this.config, card: newCardConfig });
},
});
}
private _saveFooterConfig(footerConfig: LovelaceViewFooterConfig) {
const viewConfig = this.lovelace.config.views[
this.viewIndex
] as LovelaceViewConfig;
const config = { ...viewConfig, footer: footerConfig };
const updatedConfig = replaceView(
this.hass,
this.lovelace.config,
this.viewIndex,
config
);
this.lovelace.saveConfig(updatedConfig);
}
private _renderCard(card: HuiCard, editMode: boolean) {
const gridOptions = card.getGridOptions();
const { rows } = computeCardGridSize(gridOptions);
return html`
<div
class="card ${classMap({
"fit-rows": typeof rows === "number",
})}"
style=${styleMap({
"--row-size": typeof rows === "number" ? String(rows) : undefined,
})}
>
${editMode
? html`
<hui-card-edit-mode
@ll-edit-card=${this._editCard}
@ll-delete-card=${this._deleteCard}
.hass=${this.hass}
.lovelace=${this.lovelace!}
.path=${[0]}
no-duplicate
no-move
>
${card}
</hui-card-edit-mode>
`
: card}
</div>
`;
}
render() {
if (!this.lovelace) return nothing;
const editMode = Boolean(this.lovelace?.editMode);
const card = this.card;
if (!card && !editMode) return nothing;
const columnSpan = this.config?.column_span || 1;
return html`
<div
class=${classMap({ wrapper: true, "edit-mode": editMode })}
style=${styleMap({
"--footer-column-span": String(columnSpan),
})}
>
${editMode
? html`
<div class="actions-container">
<div class="actions">
<ha-icon-button
.label=${this.hass.localize("ui.common.edit")}
@click=${this._configure}
.path=${mdiPencil}
></ha-icon-button>
</div>
</div>
`
: nothing}
<div class=${classMap({ container: true, "edit-mode": editMode })}>
${card
? this._renderCard(card, editMode)
: editMode
? html`
<button class="add" @click=${this._addCard}>
<ha-ripple></ha-ripple>
<ha-svg-icon .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize(
"ui.panel.lovelace.editor.edit_view_footer.add"
)}
</button>
`
: nothing}
</div>
</div>
`;
}
static styles = css`
:host([hidden]) {
display: none !important;
}
:host([sticky]) {
position: sticky;
bottom: 0;
z-index: 4;
}
.wrapper {
padding: var(--ha-space-4) 0;
padding-bottom: max(
var(--ha-space-4),
var(--safe-area-inset-bottom, 0px)
);
box-sizing: content-box;
margin: 0 auto;
max-width: calc(
var(--footer-column-span, 1) * var(--column-max-width, 500px) +
(var(--footer-column-span, 1) - 1) * var(--column-gap, 32px)
);
}
.wrapper:not(.edit-mode) {
--ha-card-box-shadow:
0px 3px 5px -1px rgba(0, 0, 0, 0.2),
0px 6px 10px 0px rgba(0, 0, 0, 0.14),
0px 1px 18px 0px rgba(0, 0, 0, 0.12);
--ha-card-border-color: var(--divider-color);
}
.container {
--row-height: var(--ha-section-grid-row-height, 56px);
--row-gap: var(--ha-section-grid-row-gap, 8px);
--column-gap: var(--ha-section-grid-column-gap, 8px);
position: relative;
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
grid-auto-rows: auto;
row-gap: var(--row-gap);
column-gap: var(--column-gap);
}
.container.edit-mode {
padding: var(--ha-space-2);
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
border: 2px dashed var(--divider-color);
border-start-end-radius: 0;
}
.card {
position: relative;
grid-column: 1 / -1;
grid-row: span var(--row-size, 1);
max-height: 25vh;
max-height: 25dvh;
}
.card.fit-rows {
height: calc(
(var(--row-size, 1) * (var(--row-height) + var(--row-gap))) - var(
--row-gap
)
);
}
.actions-container {
position: relative;
height: 34px;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.actions {
z-index: 1;
position: absolute;
height: 36px;
bottom: -2px;
right: 0;
inset-inline-end: 0;
inset-inline-start: initial;
opacity: 1;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s ease-in-out;
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
background: var(--secondary-background-color);
--mdc-icon-button-size: 36px;
--mdc-icon-size: 20px;
color: var(--primary-text-color);
}
.add {
grid-column: 1 / -1;
margin: 0 auto;
position: relative;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
outline: none;
gap: var(--ha-space-2);
height: 36px;
padding: 6px 20px 6px 20px;
box-sizing: border-box;
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
background-color: transparent;
border-width: 2px;
border-style: dashed;
border-color: var(--primary-color);
--mdc-icon-size: 18px;
cursor: pointer;
font-size: var(--ha-font-size-m);
color: var(--primary-text-color);
--ha-ripple-color: var(--primary-color);
--ha-ripple-hover-opacity: 0.04;
--ha-ripple-pressed-opacity: 0.12;
}
.add:focus {
border-style: solid;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-view-footer": HuiViewFooter;
}
}

View File

@@ -32,7 +32,7 @@ export const securityEntityFilters: EntityFilter[] = [
},
{
domain: "cover",
device_class: ["door", "garage", "gate", "window"],
device_class: ["door", "garage", "gate"],
entity_category: "none",
},
{

View File

@@ -186,10 +186,24 @@ export const haStyleDialog = css`
var(--safe-area-inset-right, 0) var(--safe-area-inset-bottom, 0)
var(--safe-area-inset-left, 0);
--vertical-align-dialog: flex-end;
}
ha-dialog {
--ha-dialog-border-radius: var(--ha-border-radius-square);
}
ha-dialog,
ha-adaptive-dialog {
--mdc-dialog-min-width: 100vw;
--mdc-dialog-max-width: 100vw;
--mdc-dialog-min-height: 100vh;
--mdc-dialog-min-height: 100svh;
--mdc-dialog-max-height: 100vh;
--mdc-dialog-max-height: 100svh;
--dialog-container-padding: 0px;
--dialog-surface-padding: var(--safe-area-inset-top, 0)
var(--safe-area-inset-right, 0) var(--safe-area-inset-bottom, 0)
var(--safe-area-inset-left, 0);
--vertical-align-dialog: flex-end;
}
ha-dialog {
--ha-dialog-border-radius: var(--ha-border-radius-square);
}
}
.error {
color: var(--error-color);

View File

@@ -4,7 +4,7 @@ export const waColorStyles = css`
html {
--wa-color-brand-fill-loud: var(--ha-color-fill-primary-loud-resting);
--wa-color-brand-fill-normal: var(--ha-color-fill-primary-normal-resting);
--wa-color-brand-fill-quiet: var(--ha-color-fill-primary-quiet-hover);
--wa-color-brand-fill-quiet: var(--ha-color-fill-primary-quiet-resting);
--wa-color-brand-border-loud: var(--ha-color-border-loud);
--wa-color-brand-border-normal: var(--ha-color-primary-50);
--wa-color-brand-border-quiet: var(--ha-color-border-quiet);
@@ -14,7 +14,7 @@ export const waColorStyles = css`
--wa-color-neutral-fill-loud: var(--ha-color-fill-neutral-loud-resting);
--wa-color-neutral-fill-normal: var(--ha-color-fill-neutral-normal-resting);
--wa-color-neutral-fill-quiet: var(--ha-color-fill-neutral-quiet-hover);
--wa-color-neutral-fill-quiet: var(--ha-color-fill-neutral-quiet-resting);
--wa-color-neutral-border-loud: var(--ha-color-border-neutral-loud);
--wa-color-neutral-border-normal: var(--ha-color-border-neutral-normal);
--wa-color-neutral-border-quiet: var(--ha-color-border-neutral-quiet);
@@ -24,7 +24,7 @@ export const waColorStyles = css`
--wa-color-success-fill-loud: var(--ha-color-fill-success-loud-resting);
--wa-color-success-fill-normal: var(--ha-color-fill-success-normal-resting);
--wa-color-success-fill-quiet: var(--ha-color-fill-success-quiet-hover);
--wa-color-success-fill-quiet: var(--ha-color-fill-success-quiet-resting);
--wa-color-success-border-loud: var(--ha-color-border-success-loud);
--wa-color-success-border-normal: var(--ha-color-border-success-normal);
--wa-color-success-border-quiet: var(--ha-color-border-success-quiet);
@@ -34,7 +34,7 @@ export const waColorStyles = css`
--wa-color-warning-fill-loud: var(--ha-color-fill-warning-loud-resting);
--wa-color-warning-fill-normal: var(--ha-color-fill-warning-normal-resting);
--wa-color-warning-fill-quiet: var(--ha-color-fill-warning-quiet-hover);
--wa-color-warning-fill-quiet: var(--ha-color-fill-warning-quiet-resting);
--wa-color-warning-border-loud: var(--ha-color-border-warning-loud);
--wa-color-warning-border-normal: var(--ha-color-border-warning-normal);
--wa-color-warning-border-quiet: var(--ha-color-border-warning-quiet);
@@ -44,7 +44,7 @@ export const waColorStyles = css`
--wa-color-danger-fill-loud: var(--ha-color-fill-danger-loud-resting);
--wa-color-danger-fill-normal: var(--ha-color-fill-danger-normal-resting);
--wa-color-danger-fill-quiet: var(--ha-color-fill-danger-quiet-hover);
--wa-color-danger-fill-quiet: var(--ha-color-fill-danger-quiet-resting);
--wa-color-danger-border-loud: var(--ha-color-border-danger-loud);
--wa-color-danger-border-normal: var(--ha-color-border-danger-normal);
--wa-color-danger-border-quiet: var(--ha-color-border-danger-quiet);
@@ -64,10 +64,5 @@ export const waColorStyles = css`
--wa-focus-ring-color: var(--ha-color-neutral-60);
--wa-shadow-l: 4px 8px 12px 0 rgba(0, 0, 0, 0.3);
--wa-form-control-background-color: var(--wa-color-surface-raised);
--wa-form-control-border-color: var(--ha-color-border-neutral-quiet);
--wa-form-control-value-color: var(--primary-text-color);
--wa-form-control-placeholder-color: var(--ha-color-text-secondary);
}
`;

View File

@@ -14,8 +14,10 @@ export const waMainStyles = css`
--wa-space-l: var(--ha-space-6);
--wa-space-xl: var(--ha-space-8);
--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-body: var(--ha-font-weight-normal);
--wa-transition-normal: 150ms;
--wa-transition-fast: 75ms;
--wa-transition-easing: ease;
@@ -27,25 +29,13 @@ export const waMainStyles = css`
--wa-border-radius-s: var(--ha-border-radius-sm);
--wa-border-radius-m: var(--ha-border-radius-md);
--wa-border-radius-l: var(--ha-border-radius-lg);
--wa-border-radius-pill: var(--ha-border-radius-pill);
--wa-line-height-condensed: var(--ha-line-height-condensed);
--wa-font-size-s: var(--ha-font-size-s);
--wa-font-size-m: var(--ha-font-size-m);
--wa-font-size-l: var(--ha-font-size-l);
--wa-shadow-s: var(--ha-box-shadow-s);
--wa-shadow-m: var(--ha-box-shadow-m);
--wa-shadow-l: var(--ha-box-shadow-l);
--wa-form-control-padding-block: 0.75em;
--wa-form-control-value-line-height: var(--wa-line-height-condensed);
--wa-form-control-value-font-weight: var(--wa-font-weight-body);
--wa-form-control-border-radius: var(--wa-border-radius-l);
--wa-form-control-border-style: var(--wa-border-style);
--wa-form-control-border-width: var(--wa-border-width-s);
--wa-form-control-height: 40px;
--wa-form-control-padding-inline: var(--ha-space-3);
}
${scrollLockStyles}

View File

@@ -30,10 +30,6 @@ import { subscribeEntityRegistryDisplay } from "../data/ws-entity_registry_displ
import { subscribeFloorRegistry } from "../data/ws-floor_registry";
import { subscribePanels } from "../data/ws-panels";
import { translationMetadata } from "../resources/translations-metadata";
import {
clearBrandsTokenRefresh,
fetchAndScheduleBrandsAccessToken,
} from "../util/brands-url";
import type { Constructor, HomeAssistant, ServiceCallResponse } from "../types";
import { getLocalLanguage } from "../util/common-translation";
import { fetchWithAuth } from "../util/fetch-with-auth";
@@ -323,10 +319,6 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
this._updateHass({ systemData: {} });
});
clearInterval(this.__backendPingInterval);
// Fetch the brands access token on initial connect and schedule refresh
fetchAndScheduleBrandsAccessToken(this.hass!);
this.__backendPingInterval = setInterval(() => {
if (this.hass?.connected) {
// If the backend is busy, or the connection is latent,
@@ -351,9 +343,6 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
this._updateHass({ connected: true });
broadcastConnectionStatus("connected");
// Refresh the brands access token on reconnect and restart refresh schedule
fetchAndScheduleBrandsAccessToken(this.hass!);
// on reconnect always fetch config as we might miss an update while we were disconnected
// @ts-ignore
this.hass!.callWS({ type: "get_config" }).then((config: HassConfig) => {
@@ -371,6 +360,5 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
this._updateHass({ connected: false });
broadcastConnectionStatus("disconnected");
clearInterval(this.__backendPingInterval);
clearBrandsTokenRefresh();
}
};

View File

@@ -2,6 +2,7 @@ import { ContextProvider } from "@lit/context";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import {
areasContext,
authContext,
configContext,
connectionContext,
devicesContext,
@@ -101,6 +102,10 @@ export const contextMixin = <T extends Constructor<HassBaseEl>>(
context: labelsContext,
initialValue: [],
}),
auth: new ContextProvider(this, {
context: authContext,
initialValue: this.hass?.auth,
}),
};
protected hassConnected() {

View File

@@ -32,7 +32,7 @@ export const dialogManagerMixin = <T extends Constructor<HassBaseEl>>(
this.addEventListener("register-dialog", (e) =>
this.registerDialog(e.detail)
);
makeDialogManager(this, this.shadowRoot!);
makeDialogManager(this);
}
protected registerDialog({
@@ -44,10 +44,10 @@ export const dialogManagerMixin = <T extends Constructor<HassBaseEl>>(
this.addEventListener(dialogShowEvent, (showEv) => {
showDialog(
this,
this.shadowRoot!,
dialogTag,
(showEv as HASSDomEvent<unknown>).detail,
dialogImport,
undefined,
addHistory
);
});

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