Compare commits

..

5 Commits

Author SHA1 Message Date
Petar Petrov
9331282521 Apply suggestions from code review 2025-11-17 17:04:56 +02:00
Bram Kragten
9299b84708 clean up, review 2025-11-17 15:48:04 +01:00
Bram Kragten
df7a36e743 Update text 2025-11-14 16:24:46 +01:00
Bram Kragten
5786fe4b8d update link 2025-11-14 16:18:33 +01:00
Bram Kragten
6fa274e4bf Add device database toggle to analytics 2025-11-14 16:10:46 +01:00
57 changed files with 706 additions and 2035 deletions

View File

@@ -36,14 +36,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/autobuild@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -57,4 +57,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2

File diff suppressed because one or more lines are too long

View File

@@ -6,4 +6,4 @@ enableGlobalCache: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.11.0.cjs
yarnPath: .yarn/releases/yarn-4.10.3.cjs

View File

@@ -115,7 +115,7 @@
"home-assistant-js-websocket": "9.5.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "10.7.18",
"js-yaml": "4.1.1",
"js-yaml": "4.1.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
@@ -194,7 +194,7 @@
"eslint-plugin-wc": "3.0.2",
"fancy-log": "2.0.0",
"fs-extra": "11.3.2",
"glob": "11.1.0",
"glob": "11.0.3",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
@@ -235,7 +235,7 @@
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
},
"packageManager": "yarn@4.11.0",
"packageManager": "yarn@4.10.3",
"volta": {
"node": "22.21.1"
}

View File

@@ -1,67 +0,0 @@
import { tinykeys } from "tinykeys";
import { canOverrideAlphanumericInput } from "../dom/can-override-input";
/**
* A function to handle a keyboard shortcut.
*/
export type ShortcutHandler = (event: KeyboardEvent) => void;
/**
* Configuration for a keyboard shortcut.
*/
export interface ShortcutConfig {
handler: ShortcutHandler;
/**
* If true, allows shortcuts even when text is selected.
* Default is false to avoid interrupting copy/paste.
*/
allowWhenTextSelected?: boolean;
}
/**
* Register keyboard shortcuts using tinykeys.
* Automatically blocks shortcuts in input fields and during text selection.
*/
function registerShortcuts(
shortcuts: Record<string, ShortcutConfig>
): () => void {
const wrappedShortcuts: Record<string, ShortcutHandler> = {};
Object.entries(shortcuts).forEach(([key, config]) => {
wrappedShortcuts[key] = (event: KeyboardEvent) => {
if (!canOverrideAlphanumericInput(event.composedPath())) {
return;
}
if (!config.allowWhenTextSelected && window.getSelection()?.toString()) {
return;
}
config.handler(event);
};
});
return tinykeys(window, wrappedShortcuts);
}
/**
* Manages keyboard shortcuts registration and cleanup.
*/
export class ShortcutManager {
private _disposer?: () => void;
/**
* Register keyboard shortcuts.
* Uses tinykeys syntax: https://github.com/jamiebuilds/tinykeys#usage
*/
public add(shortcuts: Record<string, ShortcutConfig>) {
this._disposer?.();
this._disposer = registerShortcuts(shortcuts);
}
/**
* Remove all registered shortcuts.
*/
public remove() {
this._disposer?.();
this._disposer = undefined;
}
}

View File

@@ -30,7 +30,6 @@ export class HaFilterChip extends FilterChip {
var(--rgb-primary-text-color),
0.15
);
--_label-text-font: var(--ha-font-family-body);
border-radius: var(--ha-border-radius-md);
}
`,

View File

@@ -298,18 +298,6 @@ export class HaDataTable extends LitElement {
}
if (properties.has("data")) {
// Clean up checked rows that no longer exist in the data
if (this._checkedRows.length) {
const validIds = new Set(this.data.map((row) => String(row[this.id])));
const validCheckedRows = this._checkedRows.filter((id) =>
validIds.has(id)
);
if (validCheckedRows.length !== this._checkedRows.length) {
this._checkedRows = validCheckedRows;
this._checkedRowsChanged();
}
}
this._checkableRowsCount = this.data.filter(
(row) => row.selectable !== false
).length;

View File

@@ -94,12 +94,6 @@ export class HaDateInput extends LitElement {
}
private _keyDown(ev: KeyboardEvent) {
if (["Space", "Enter"].includes(ev.code)) {
ev.preventDefault();
ev.stopPropagation();
this._openDialog();
return;
}
if (!this.canClear) {
return;
}

View File

@@ -100,7 +100,6 @@ export class HaDialog extends DialogBase {
}
.mdc-dialog__container {
align-items: var(--vertical-align-dialog, center);
padding: var(--dialog-container-padding, var(--ha-space-0));
}
.mdc-dialog__title {
padding: 16px 16px 0 16px;
@@ -113,10 +112,10 @@ export class HaDialog extends DialogBase {
}
.mdc-dialog .mdc-dialog__content {
position: var(--dialog-content-position, relative);
padding: var(--dialog-content-padding, var(--ha-space-6));
padding: var(--dialog-content-padding, 24px);
}
:host([hideactions]) .mdc-dialog .mdc-dialog__content {
padding-bottom: var(--dialog-content-padding, var(--ha-space-6));
padding-bottom: var(--dialog-content-padding, 24px);
}
.mdc-dialog .mdc-dialog__surface {
position: var(--dialog-surface-position, relative);
@@ -134,7 +133,7 @@ export class HaDialog extends DialogBase {
--ha-dialog-surface-background,
var(--mdc-theme-surface, #fff)
);
padding: var(--dialog-surface-padding, var(--ha-space-0));
padding: var(--dialog-surface-padding);
}
:host([flexContent]) .mdc-dialog .mdc-dialog__content {
display: flex;

View File

@@ -60,10 +60,6 @@ class HaHLSPlayer extends LitElement {
private static streamCount = 0;
private _handleVisibilityChange = () => {
if (document.pictureInPictureElement) {
// video is playing in picture-in-picture mode, don't do anything
return;
}
if (document.hidden) {
this._cleanUp();
} else {

View File

@@ -62,10 +62,6 @@ class HaWebRtcPlayer extends LitElement {
private _candidatesList: RTCIceCandidate[] = [];
private _handleVisibilityChange = () => {
if (document.pictureInPictureElement) {
// video is playing in picture-in-picture mode, don't do anything
return;
}
if (document.hidden) {
this._cleanUp();
} else {

View File

@@ -18,7 +18,7 @@ import {
removeLocalMedia,
} from "../../data/media_source";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import { haStyleDialog, haStyleDialogFixedTop } from "../../resources/styles";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "../ha-button";
import "../ha-check-list-item";
@@ -305,7 +305,6 @@ class DialogMediaManage extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-dialog {
--dialog-z-index: 9;
@@ -315,6 +314,8 @@ class DialogMediaManage extends LitElement {
@media (min-width: 800px) {
ha-dialog {
--mdc-dialog-max-width: 800px;
--dialog-surface-position: fixed;
--dialog-surface-top: 40px;
--mdc-dialog-max-height: calc(100vh - 72px);
}
}

View File

@@ -19,7 +19,7 @@ import type {
MediaPlayerItem,
MediaPlayerLayoutType,
} from "../../data/media-player";
import { haStyleDialog, haStyleDialogFixedTop } from "../../resources/styles";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "../ha-dialog";
import "../ha-dialog-header";
@@ -223,7 +223,6 @@ class DialogMediaPlayerBrowse extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-dialog {
--dialog-z-index: 9;
@@ -231,45 +230,23 @@ class DialogMediaPlayerBrowse extends LitElement {
}
ha-media-player-browse {
--media-browser-max-height: calc(
100vh -
65px - var(--safe-area-inset-top, var(--ha-space-0)) - var(
--safe-area-inset-bottom,
var(--ha-space-0)
)
);
--media-browser-max-height: calc(100vh - 65px);
}
:host(.opened) ha-media-player-browse {
height: calc(
100vh -
65px - var(--safe-area-inset-top, var(--ha-space-0)) - var(
--safe-area-inset-bottom,
var(--ha-space-0)
)
);
height: calc(100vh - 65px);
}
@media (min-width: 800px) {
ha-dialog {
--mdc-dialog-max-width: 800px;
--mdc-dialog-max-height: calc(
100vh -
72px - var(--safe-area-inset-top, var(--ha-space-0)) - var(
--safe-area-inset-bottom,
var(--ha-space-0)
)
);
--dialog-surface-position: fixed;
--dialog-surface-top: 40px;
--mdc-dialog-max-height: calc(100vh - 72px);
}
ha-media-player-browse {
position: initial;
--media-browser-max-height: calc(
100vh -
145px - var(--safe-area-inset-top, var(--ha-space-0)) - var(
--safe-area-inset-bottom,
var(--ha-space-0)
)
);
--media-browser-max-height: calc(100vh - 145px);
width: 700px;
}
}

View File

@@ -34,7 +34,6 @@ class SearchInput extends LitElement {
return html`
<ha-textfield
.autofocus=${this.autofocus}
autocomplete="off"
.label=${this.label || this.hass.localize("ui.common.search")}
.value=${this.filter || ""}
icon

View File

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

View File

@@ -31,7 +31,6 @@ export interface CalendarEventData {
dtend: string;
rrule?: string;
description?: string;
location?: string;
}
export interface CalendarEventMutableParams {
@@ -40,7 +39,6 @@ export interface CalendarEventMutableParams {
dtend: string;
rrule?: string;
description?: string;
location?: string;
}
// The scope of a delete/update for a recurring event
@@ -98,7 +96,6 @@ export const fetchCalendarEvents = async (
uid: ev.uid,
summary: ev.summary,
description: ev.description,
location: ev.location,
dtstart: eventStart,
dtend: eventEnd,
recurrence_id: ev.recurrence_id,

View File

@@ -50,7 +50,7 @@ import { lightSupportsFavoriteColors } from "../../data/light";
import type { ItemType } from "../../data/search";
import { SearchableDomains } from "../../data/search";
import { getSensorNumericDeviceClasses } from "../../data/sensor";
import { haStyleDialog, haStyleDialogFixedTop } from "../../resources/styles";
import { haStyleDialog } from "../../resources/styles";
import "../../state-summary/state-card-content";
import type { HomeAssistant } from "../../types";
import {
@@ -707,9 +707,14 @@ export class MoreInfoDialog extends LitElement {
static get styles() {
return [
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-dialog {
/* Set the top top of the dialog to a fixed position, so it doesnt jump when the content changes size */
--vertical-align-dialog: flex-start;
--dialog-surface-margin-top: max(
var(--ha-space-10),
var(--safe-area-inset-top, var(--ha-space-0))
);
--dialog-content-padding: 0;
}
@@ -732,6 +737,13 @@ export class MoreInfoDialog extends LitElement {
display: block;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
/* When in fullscreen dialog should be attached to top */
ha-dialog {
--dialog-surface-margin-top: var(--ha-space-0);
}
}
@media all and (min-width: 600px) and (min-height: 501px) {
ha-dialog {
--mdc-dialog-min-width: 580px;

View File

@@ -46,11 +46,7 @@ import { getPanelNameTranslationKey } from "../../data/panel";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import { configSections } from "../../panels/config/ha-panel-config";
import { HaFuse } from "../../resources/fuse";
import {
haStyleDialog,
haStyleDialogFixedTop,
haStyleScrollbar,
} from "../../resources/styles";
import { haStyleDialog, haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
@@ -990,7 +986,6 @@ export class QuickBar extends LitElement {
return [
haStyleScrollbar,
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-list {
position: relative;
@@ -1015,6 +1010,8 @@ export class QuickBar extends LitElement {
ha-dialog {
--mdc-dialog-max-width: 800px;
--mdc-dialog-min-width: 500px;
--dialog-surface-position: fixed;
--dialog-surface-top: var(--ha-space-10);
--mdc-dialog-max-height: calc(100% - var(--ha-space-18));
}
}

View File

@@ -80,12 +80,10 @@ class DialogCalendarEventDetail extends LitElement {
${this._data!.rrule
? this._renderRRuleAsText(this._data.rrule)
: ""}
${this._data.location
? html`${this._data.location} <br />`
: nothing}
${this._data.description
? html`<br />
<div class="description">${this._data.description}</div>`
<div class="description">${this._data.description}</div>
<br />`
: nothing}
</div>
</div>
@@ -243,7 +241,7 @@ class DialogCalendarEventDetail extends LitElement {
haStyleDialog,
css`
state-info {
margin-top: 24px;
line-height: 40px;
}
ha-svg-icon {
width: 40px;

View File

@@ -63,8 +63,6 @@ class DialogCalendarEventEditor extends LitElement {
@state() private _description? = "";
@state() private _location? = "";
@state() private _rrule?: string;
@state() private _allDay = false;
@@ -81,8 +79,6 @@ class DialogCalendarEventEditor extends LitElement {
// timezone, but floating without a timezone.
private _timeZone?: string;
private _hasLocation = false;
public showDialog(params: CalendarEventEditDialogParams): void {
this._error = undefined;
this._info = undefined;
@@ -103,10 +99,6 @@ class DialogCalendarEventEditor extends LitElement {
this._allDay = isDate(entry.dtstart);
this._summary = entry.summary;
this._description = entry.description;
if (entry.location) {
this._hasLocation = true;
this._location = entry.location;
}
this._rrule = entry.rrule;
if (this._allDay) {
this._dtstart = new Date(entry.dtstart + "T00:00:00");
@@ -138,8 +130,6 @@ class DialogCalendarEventEditor extends LitElement {
this._dtend = undefined;
this._summary = "";
this._description = "";
this._location = "";
this._hasLocation = false;
this._rrule = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -191,15 +181,6 @@ class DialogCalendarEventEditor extends LitElement {
.validationMessage=${this.hass.localize("ui.common.error_required")}
dialogInitialFocus
></ha-textfield>
<ha-textfield
class="location"
name="location"
.label=${this.hass.localize(
"ui.components.calendar.event.location"
)}
.value=${this._location}
@change=${this._handleLocationChanged}
></ha-textfield>
<ha-textarea
class="description"
name="description"
@@ -345,10 +326,6 @@ class DialogCalendarEventEditor extends LitElement {
this._description = ev.target.value;
}
private _handleLocationChanged(ev: Event) {
this._location = (ev.target as HTMLInputElement).value;
}
private _handleRRuleChanged(ev) {
this._rrule = ev.detail.value;
}
@@ -422,7 +399,6 @@ class DialogCalendarEventEditor extends LitElement {
const data: CalendarEventMutableParams = {
summary: this._summary,
description: this._description,
location: this._location || (this._hasLocation ? "" : undefined),
rrule: this._rrule || undefined,
dtstart: "",
dtend: "",

View File

@@ -1336,7 +1336,7 @@ class DialogAddAutomationElement
--md-list-item-trailing-space: var(--md-list-item-leading-space);
--md-list-item-bottom-space: var(--ha-space-1);
--md-list-item-top-space: var(--md-list-item-bottom-space);
--md-list-item-supporting-text-font: var(--ha-font-family-body);
--md-list-item-supporting-text-font: var(--ha-font-size-s);
--md-list-item-one-line-container-height: var(--ha-space-10);
}
ha-bottom-sheet .groups {
@@ -1400,7 +1400,7 @@ class DialogAddAutomationElement
--md-list-item-trailing-space: var(--md-list-item-leading-space);
--md-list-item-bottom-space: var(--ha-space-2);
--md-list-item-top-space: var(--md-list-item-bottom-space);
--md-list-item-supporting-text-font: var(--ha-font-family-body);
--md-list-item-supporting-text-font: var(--ha-font-size-s);
gap: var(--ha-space-2);
padding: var(--ha-space-0) var(--ha-space-4);
}

View File

@@ -1161,9 +1161,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
private async _delete(automation) {
try {
await deleteAutomation(this.hass, automation.attributes.id);
this._selected = this._selected.filter(
(entityId) => entityId !== automation.entity_id
);
} catch (err: any) {
await showAlertDialog(this, {
text:

View File

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

View File

@@ -462,7 +462,7 @@ class AddIntegrationDialog extends LitElement {
style=${styleMap({
width: `${this._width}px`,
height: this._narrow
? "calc(100vh - 184px - var(--safe-area-inset-top, var(--ha-space-0)) - var(--safe-area-inset-bottom, var(--ha-space-0)))"
? "calc(100vh - 184px - var(--safe-area-inset-top, 0px) - var(--safe-area-inset-bottom, 0px))"
: "500px",
})}
@click=${this._integrationPicked}

View File

@@ -87,7 +87,7 @@ class HaConfigEntryDeviceRow extends LitElement {
${!this.narrow
? html`<ha-icon-button
slot="end"
@click=${this._handleEditDeviceButton}
@click=${this._handleEditDevice}
.path=${mdiPencil}
.label=${this.hass.localize(
"ui.panel.config.integrations.config_entry.device.edit"
@@ -106,7 +106,7 @@ class HaConfigEntryDeviceRow extends LitElement {
.path=${mdiDotsVertical}
></ha-icon-button>
${this.narrow
? html`<ha-md-menu-item .clickAction=${this._handleEditDevice}>
? html`<ha-md-menu-item @click=${this._handleEditDevice}>
<ha-svg-icon .path=${mdiPencil} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.device.edit"
@@ -115,7 +115,7 @@ class HaConfigEntryDeviceRow extends LitElement {
: nothing}
${entities.length
? html`
<ha-md-menu-item .clickAction=${this._handleNavigateToEntities}>
<ha-md-menu-item @click=${this._handleNavigateToEntities}>
<ha-svg-icon
.path=${mdiShapeOutline}
slot="start"
@@ -130,7 +130,7 @@ class HaConfigEntryDeviceRow extends LitElement {
: nothing}
<ha-md-menu-item
class=${device.disabled_by !== "user" ? "warning" : ""}
.clickAction=${this._handleDisableDevice}
@click=${this._handleDisableDevice}
.disabled=${device.disabled_by !== "user" && device.disabled_by}
>
<ha-svg-icon .path=${mdiStopCircleOutline} slot="start"></ha-svg-icon>
@@ -160,7 +160,7 @@ class HaConfigEntryDeviceRow extends LitElement {
${this.entry.supports_remove_device
? html`<ha-md-menu-item
class="warning"
.clickAction=${this._handleDeleteDevice}
@click=${this._handleDeleteDevice}
>
<ha-svg-icon .path=${mdiDelete} slot="start"></ha-svg-icon>
${this.hass.localize(
@@ -175,25 +175,21 @@ class HaConfigEntryDeviceRow extends LitElement {
private _getEntities = (): EntityRegistryEntry[] =>
this.entities?.filter((entity) => entity.device_id === this.device.id);
private _handleEditDeviceButton(ev: MouseEvent) {
private _handleEditDevice(ev: MouseEvent) {
ev.stopPropagation(); // Prevent triggering the click handler on the list item
this._handleEditDevice();
}
private _handleEditDevice = () => {
showDeviceRegistryDetailDialog(this, {
device: this.device,
updateEntry: async (updates) => {
await updateDeviceRegistryEntry(this.hass, this.device.id, updates);
},
});
};
}
private _handleNavigateToEntities = () => {
private _handleNavigateToEntities() {
navigate(`/config/entities/?historyBack=1&device=${this.device.id}`);
};
}
private _handleDisableDevice = async () => {
private async _handleDisableDevice() {
const disable = this.device.disabled_by === null;
if (disable) {
@@ -267,9 +263,9 @@ class HaConfigEntryDeviceRow extends LitElement {
await updateDeviceRegistryEntry(this.hass, this.device.id, {
disabled_by: disable ? "user" : null,
});
};
}
private _handleDeleteDevice = async () => {
private async _handleDeleteDevice() {
const entry = this.entry;
const confirmed = await showConfirmationDialog(this, {
text: this.hass.localize("ui.panel.config.devices.confirm_delete"),
@@ -294,7 +290,7 @@ class HaConfigEntryDeviceRow extends LitElement {
text: err.message,
});
}
};
}
private _handleNavigateToDevice() {
navigate(`/config/devices/device/${this.device.id}`);

View File

@@ -302,7 +302,7 @@ class HaConfigEntryRow extends LitElement {
item.supports_unload &&
item.source !== "system"
? html`
<ha-md-menu-item .clickAction=${this._handleReload}>
<ha-md-menu-item @click=${this._handleReload}>
<ha-svg-icon slot="start" .path=${mdiReload}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.reload"
@@ -311,14 +311,14 @@ class HaConfigEntryRow extends LitElement {
`
: nothing}
<ha-md-menu-item .clickAction=${this._handleRename} graphic="icon">
<ha-md-menu-item @click=${this._handleRename} graphic="icon">
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.rename"
)}
</ha-md-menu-item>
<ha-md-menu-item .clickAction=${this._handleCopy} graphic="icon">
<ha-md-menu-item @click=${this._handleCopy} graphic="icon">
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.copy"
@@ -328,7 +328,7 @@ class HaConfigEntryRow extends LitElement {
${Object.keys(item.supported_subentry_types).map(
(flowType) =>
html`<ha-md-menu-item
.clickAction=${this._addSubEntry}
@click=${this._addSubEntry}
.entry=${item}
.flowType=${flowType}
graphic="icon"
@@ -360,7 +360,7 @@ class HaConfigEntryRow extends LitElement {
item.supports_reconfigure &&
item.source !== "system"
? html`
<ha-md-menu-item .clickAction=${this._handleReconfigure}>
<ha-md-menu-item @click=${this._handleReconfigure}>
<ha-svg-icon slot="start" .path=${mdiWrench}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.reconfigure"
@@ -369,10 +369,7 @@ class HaConfigEntryRow extends LitElement {
`
: nothing}
<ha-md-menu-item
.clickAction=${this._handleSystemOptions}
graphic="icon"
>
<ha-md-menu-item @click=${this._handleSystemOptions} graphic="icon">
<ha-svg-icon slot="start" .path=${mdiCogOutline}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.system_options"
@@ -380,7 +377,7 @@ class HaConfigEntryRow extends LitElement {
</ha-md-menu-item>
${item.disabled_by === "user"
? html`
<ha-md-menu-item .clickAction=${this._handleEnable}>
<ha-md-menu-item @click=${this._handleEnable}>
<ha-svg-icon
slot="start"
.path=${mdiPlayCircleOutline}
@@ -392,7 +389,7 @@ class HaConfigEntryRow extends LitElement {
? html`
<ha-md-menu-item
class="warning"
.clickAction=${this._handleDisable}
@click=${this._handleDisable}
graphic="icon"
>
<ha-svg-icon
@@ -406,10 +403,7 @@ class HaConfigEntryRow extends LitElement {
: nothing}
${item.source !== "system"
? html`
<ha-md-menu-item
class="warning"
.clickAction=${this._handleDelete}
>
<ha-md-menu-item class="warning" @click=${this._handleDelete}>
<ha-svg-icon
slot="start"
class="warning"
@@ -617,7 +611,7 @@ class HaConfigEntryRow extends LitElement {
}
}
private _handleReload = async () => {
private async _handleReload() {
const result = await reloadConfigEntry(this.hass, this.entry.entry_id);
const locale_key = result.require_restart
? "reload_restart_confirm"
@@ -627,9 +621,9 @@ class HaConfigEntryRow extends LitElement {
`ui.panel.config.integrations.config_entry.${locale_key}`
),
});
};
}
private _handleReconfigure = async () => {
private async _handleReconfigure() {
showConfigFlowDialog(this, {
startFlowHandler: this.entry.domain,
showAdvanced: this.hass.userData?.showAdvanced,
@@ -637,18 +631,18 @@ class HaConfigEntryRow extends LitElement {
entryId: this.entry.entry_id,
navigateToResult: true,
});
};
}
private _handleCopy = async () => {
private async _handleCopy() {
await copyToClipboard(this.entry.entry_id);
showToast(this, {
message:
this.hass?.localize("ui.common.copied_clipboard") ||
"Copied to clipboard",
});
};
}
private _handleRename = async () => {
private async _handleRename() {
const newName = await showPromptDialog(this, {
title: this.hass.localize("ui.panel.config.integrations.rename_dialog"),
defaultValue: this.entry.title,
@@ -662,7 +656,7 @@ class HaConfigEntryRow extends LitElement {
await updateConfigEntry(this.hass, this.entry.entry_id, {
title: newName,
});
};
}
private async _signUrl(ev) {
const anchor = ev.currentTarget;
@@ -674,7 +668,7 @@ class HaConfigEntryRow extends LitElement {
fileDownload(signedUrl.path);
}
private _handleDisable = async () => {
private async _handleDisable() {
const entryId = this.entry.entry_id;
const confirmed = await showConfirmationDialog(this, {
@@ -712,9 +706,9 @@ class HaConfigEntryRow extends LitElement {
),
});
}
};
}
private _handleEnable = async () => {
private async _handleEnable() {
const entryId = this.entry.entry_id;
let result: DisableConfigEntryResult;
@@ -737,9 +731,9 @@ class HaConfigEntryRow extends LitElement {
),
});
}
};
}
private _handleDelete = async () => {
private async _handleDelete() {
const entryId = this.entry.entry_id;
const applicationCredentialsId =
@@ -773,20 +767,20 @@ class HaConfigEntryRow extends LitElement {
if (applicationCredentialsId) {
this._removeApplicationCredential(applicationCredentialsId);
}
};
}
private _handleSystemOptions = () => {
private _handleSystemOptions() {
showConfigEntrySystemOptionsDialog(this, {
entry: this.entry,
manifest: this.manifest,
});
};
}
private _addSubEntry = (item) => {
showSubConfigFlowDialog(this, this.entry, item.flowType, {
private _addSubEntry(ev) {
showSubConfigFlowDialog(this, this.entry, ev.target.flowType, {
startFlowHandler: this.entry.entry_id,
});
};
}
static styles = [
haStyle,

View File

@@ -145,16 +145,13 @@ class HaConfigSubEntryRow extends LitElement {
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item .clickAction=${this._handleRenameSub}>
<ha-md-menu-item @click=${this._handleRenameSub}>
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.rename"
)}
</ha-md-menu-item>
<ha-md-menu-item
class="warning"
.clickAction=${this._handleDeleteSub}
>
<ha-md-menu-item class="warning" @click=${this._handleDeleteSub}>
<ha-svg-icon
slot="start"
class="warning"
@@ -225,7 +222,7 @@ class HaConfigSubEntryRow extends LitElement {
});
}
private _handleRenameSub = async (): Promise<void> => {
private async _handleRenameSub(): Promise<void> {
const newName = await showPromptDialog(this, {
title: this.hass.localize("ui.common.rename"),
defaultValue: this.subEntry.title,
@@ -242,9 +239,9 @@ class HaConfigSubEntryRow extends LitElement {
this.subEntry.subentry_id,
{ title: newName }
);
};
}
private _handleDeleteSub = async (): Promise<void> => {
private async _handleDeleteSub(): Promise<void> {
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_title",
@@ -266,7 +263,7 @@ class HaConfigSubEntryRow extends LitElement {
this.entry.entry_id,
this.subEntry.subentry_id
);
};
}
static styles = css`
.expand-button {

View File

@@ -12,10 +12,7 @@ import "../../../../../components/ha-tab-group";
import "../../../../../components/ha-tab-group-tab";
import type { ZHADevice, ZHAGroup } from "../../../../../data/zha";
import { fetchBindableDevices, fetchGroups } from "../../../../../data/zha";
import {
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../../../resources/styles";
import { haStyleDialog } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { sortZHADevices, sortZHAGroups } from "./functions";
import type {
@@ -214,11 +211,11 @@ class DialogZHAManageZigbeeDevice extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-dialog {
--dialog-surface-position: static;
--dialog-content-position: static;
--vertical-align-dialog: flex-start;
}
.content {
@@ -232,6 +229,7 @@ class DialogZHAManageZigbeeDevice extends LitElement {
ha-dialog {
--mdc-dialog-min-width: 560px;
--mdc-dialog-max-width: 560px;
--dialog-surface-margin-top: 40px;
--mdc-dialog-max-height: calc(100% - 72px);
}
}

View File

@@ -1112,9 +1112,6 @@ ${rejected
private async _delete(scene: SceneEntity): Promise<void> {
if (scene.attributes.id) {
await deleteScene(this.hass, scene.attributes.id);
this._selected = this._selected.filter(
(entityId) => entityId !== scene.entity_id
);
}
}

View File

@@ -1183,9 +1183,6 @@ ${rejected
);
if (entry) {
await deleteScript(this.hass, entry.unique_id);
this._selected = this._selected.filter(
(entityId) => entityId !== script.entity_id
);
}
} catch (err: any) {
await showAlertDialog(this, {

View File

@@ -1,4 +1,4 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { mdiPencil, mdiDownload } from "@mdi/js";
import { customElement, property, state } from "lit/decorators";
@@ -6,7 +6,6 @@ import "../../components/ha-menu-button";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-list-item";
import "../../components/ha-top-app-bar-fixed";
import "../../components/ha-alert";
import type { LovelaceConfig } from "../../data/lovelace/config/types";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
@@ -22,7 +21,6 @@ import type {
GasSourceTypeEnergyPreference,
WaterSourceTypeEnergyPreference,
DeviceConsumptionEnergyPreference,
EnergyCollection,
} from "../../data/energy";
import {
computeConsumptionData,
@@ -32,28 +30,13 @@ import {
import { fileDownload } from "../../util/file_download";
import type { StatisticValue } from "../../data/recorder";
export const DEFAULT_ENERGY_COLLECTION_KEY = "energy_dashboard";
const ENERGY_LOVELACE_CONFIG: LovelaceConfig = {
views: [
{
strategy: {
type: "energy-overview",
collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
type: "energy",
},
},
{
strategy: {
type: "energy-electricity",
collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
},
path: "electricity",
},
{
type: "panel",
path: "setup",
cards: [{ type: "custom:energy-setup-wizard-card" }],
},
],
};
@@ -63,30 +46,13 @@ class PanelEnergy extends LitElement {
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _viewIndex = 0;
@state() private _lovelace?: Lovelace;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _error?: string;
@property({ attribute: false }) public route?: {
path: string;
prefix: string;
};
private _energyCollection?: EnergyCollection;
get _viewPath(): string | undefined {
const viewPath: string | undefined = this.route!.path.split("/")[1];
return viewPath ? decodeURI(viewPath) : undefined;
}
public connectedCallback() {
super.connectedCallback();
this._loadPrefs();
}
public async willUpdate(changedProps: PropertyValues) {
public willUpdate(changedProps: PropertyValues) {
if (!this.hasUpdated) {
this.hass.loadFragmentTranslation("lovelace");
}
@@ -96,36 +62,9 @@ class PanelEnergy extends LitElement {
const oldHass = changedProps.get("hass") as this["hass"];
if (oldHass?.locale !== this.hass.locale) {
this._setLovelace();
} else if (oldHass && oldHass.localize !== this.hass.localize) {
this._reloadView();
}
}
private async _loadPrefs() {
if (this._viewPath === "setup") {
await import("./cards/energy-setup-wizard-card");
} else {
this._energyCollection = getEnergyDataCollection(this.hass, {
key: DEFAULT_ENERGY_COLLECTION_KEY,
});
try {
// Have to manually refresh here as we don't want to subscribe yet
await this._energyCollection.refresh();
} catch (err: any) {
if (err.code === "not_found") {
navigate("/energy/setup");
}
this._error = err.message;
return;
}
const prefs = this._energyCollection.prefs!;
if (
prefs.device_consumption.length === 0 &&
prefs.energy_sources.length === 0
) {
// No energy sources available, start from scratch
navigate("/energy/setup");
}
if (oldHass && oldHass.localize !== this.hass.localize) {
this._reloadView();
}
}
@@ -134,33 +73,11 @@ class PanelEnergy extends LitElement {
goBack();
}
protected render() {
if (!this._energyCollection?.prefs) {
// Still loading
return html`<div class="centered">
<ha-spinner size="large"></ha-spinner>
</div>`;
}
const { prefs } = this._energyCollection;
const isSingleView = prefs.energy_sources.every((source) =>
["grid", "solar", "battery"].includes(source.type)
);
let viewPath = this._viewPath;
if (isSingleView) {
// if only electricity sources, show electricity view directly
viewPath = "electricity";
}
const viewIndex = Math.max(
ENERGY_LOVELACE_CONFIG.views.findIndex((view) => view.path === viewPath),
0
);
const showBack =
this._searchParms.has("historyBack") || (!isSingleView && viewIndex > 0);
protected render(): TemplateResult {
return html`
<div class="header">
<div class="toolbar">
${showBack
${this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
@@ -182,7 +99,7 @@ class PanelEnergy extends LitElement {
<hui-energy-period-selector
.hass=${this.hass}
.collectionKey=${DEFAULT_ENERGY_COLLECTION_KEY}
collection-key="energy_dashboard"
>
${this.hass.user?.is_admin
? html` <ha-list-item
@@ -210,21 +127,12 @@ class PanelEnergy extends LitElement {
.hass=${this.hass}
@reload-energy-panel=${this._reloadView}
>
${this._error
? html`<div class="centered">
<ha-alert alert-type="error">
An error occurred while fetching your energy preferences:
${this._error}
</ha-alert>
</div>`
: this._lovelace
? html`<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${viewIndex}
></hui-view>`
: nothing}
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view>
</hui-view-container>
`;
}
@@ -252,7 +160,9 @@ class PanelEnergy extends LitElement {
private async _dumpCSV(ev) {
ev.stopPropagation();
const energyData = this._energyCollection!;
const energyData = getEnergyDataCollection(this.hass, {
key: "energy_dashboard",
});
if (!energyData.prefs || !energyData.state.stats) {
return;
@@ -549,11 +459,11 @@ class PanelEnergy extends LitElement {
}
private _reloadView() {
// Force strategy to be re-run by making a copy of the view
// Force strategy to be re-run by make a copy of the view
const config = this._lovelace!.config;
this._lovelace = {
...this._lovelace!,
config: { ...config, views: config.views.map((view) => ({ ...view })) },
config: { ...config, views: [{ ...config.views[0] }] },
};
}
@@ -655,13 +565,6 @@ class PanelEnergy extends LitElement {
flex: 1 1 100%;
max-width: 100%;
}
.centered {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
`,
];
}

View File

@@ -1,218 +0,0 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import type { GridSourceTypeEnergyPreference } from "../../../data/energy";
import { getEnergyDataCollection } from "../../../data/energy";
import type { HomeAssistant } from "../../../types";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy";
@customElement("energy-overview-view-strategy")
export class EnergyViewStrategy extends ReactiveElement {
static async generate(
_config: LovelaceStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const view: LovelaceViewConfig = {
type: "sections",
sections: [],
dense_section_placement: true,
max_columns: 2,
};
const collectionKey =
_config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY;
const energyCollection = getEnergyDataCollection(hass, {
key: collectionKey,
});
const prefs = energyCollection.prefs;
// No energy sources available
if (
!prefs ||
(prefs.device_consumption.length === 0 &&
prefs.energy_sources.length === 0)
) {
return view;
}
const hasGrid = prefs.energy_sources.find(
(source) =>
source.type === "grid" &&
(source.flow_from?.length || source.flow_to?.length)
) as GridSourceTypeEnergyPreference;
const hasReturn = hasGrid && hasGrid.flow_to.length > 0;
const hasSolar = prefs.energy_sources.some(
(source) => source.type === "solar"
);
const hasGas = prefs.energy_sources.some((source) => source.type === "gas");
const hasBattery = prefs.energy_sources.some(
(source) => source.type === "battery"
);
const hasWater = prefs.energy_sources.some(
(source) => source.type === "water"
);
const hasPowerSources = prefs.energy_sources.find(
(source) =>
(source.type === "solar" && source.stat_rate) ||
(source.type === "battery" && source.stat_rate) ||
(source.type === "grid" && source.power?.length)
);
const hasPowerDevices = prefs.device_consumption.find(
(device) => device.stat_rate
);
const overviewSection: LovelaceSectionConfig = {
type: "grid",
column_span: 24,
cards: [],
};
if (hasPowerSources && hasPowerDevices) {
overviewSection.cards!.push({
title: hass.localize("ui.panel.energy.cards.power_sankey_title"),
type: "power-sankey",
collection_key: collectionKey,
grid_options: {
columns: 24,
},
});
}
// Only include if we have a grid or battery.
if (hasGrid || hasBattery) {
overviewSection.cards!.push({
title: hass.localize("ui.panel.energy.cards.energy_distribution_title"),
type: "energy-distribution",
collection_key: collectionKey,
});
}
if (hasGrid || hasSolar || hasBattery || hasGas || hasWater) {
overviewSection.cards!.push({
type: "energy-sources-table",
collection_key: collectionKey,
});
}
view.sections!.push(overviewSection);
const electricitySection: LovelaceSectionConfig = {
type: "grid",
cards: [
{
type: "heading",
heading: hass.localize("ui.panel.energy.overview.electricity"),
tap_action: {
action: "navigate",
navigation_path: "/energy/electricity",
},
},
],
};
if (hasPowerSources) {
electricitySection.cards!.push({
type: "power-sources-graph",
collection_key: collectionKey,
});
}
if (prefs!.device_consumption.length > 3) {
electricitySection.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.energy_top_consumers_title"
),
type: "energy-devices-graph",
collection_key: collectionKey,
max_devices: 3,
modes: ["bar"],
});
} else if (hasGrid) {
const gauges: LovelaceCardConfig[] = [];
// Only include if we have a grid source & return.
if (hasReturn) {
gauges.push({
type: "energy-grid-neutrality-gauge",
view_layout: { position: "sidebar" },
collection_key: collectionKey,
});
}
gauges.push({
type: "energy-carbon-consumed-gauge",
view_layout: { position: "sidebar" },
collection_key: collectionKey,
});
// Only include if we have a solar source.
if (hasSolar) {
if (hasReturn) {
gauges.push({
type: "energy-solar-consumed-gauge",
view_layout: { position: "sidebar" },
collection_key: collectionKey,
});
}
gauges.push({
type: "energy-self-sufficiency-gauge",
view_layout: { position: "sidebar" },
collection_key: collectionKey,
});
}
electricitySection.cards!.push({
type: "grid",
columns: 2,
square: false,
cards: gauges,
});
}
view.sections!.push(electricitySection);
if (hasGas) {
view.sections!.push({
type: "grid",
cards: [
{
type: "heading",
heading: hass.localize("ui.panel.energy.overview.gas"),
},
{
title: hass.localize(
"ui.panel.energy.cards.energy_gas_graph_title"
),
type: "energy-gas-graph",
collection_key: collectionKey,
},
],
});
}
if (hasWater) {
view.sections!.push({
type: "grid",
cards: [
{
type: "heading",
heading: hass.localize("ui.panel.energy.overview.water"),
},
{
title: hass.localize(
"ui.panel.energy.cards.energy_water_graph_title"
),
type: "energy-water-graph",
collection_key: collectionKey,
},
],
});
}
return view;
}
}
declare global {
interface HTMLElementTagNameMap {
"energy-overview-view-strategy": EnergyViewStrategy;
}
}

View File

@@ -1,37 +1,57 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import type { GridSourceTypeEnergyPreference } from "../../../data/energy";
import { getEnergyDataCollection } from "../../../data/energy";
import type {
EnergyPreferences,
GridSourceTypeEnergyPreference,
} from "../../../data/energy";
import { getEnergyPreferences } from "../../../data/energy";
import type { HomeAssistant } from "../../../types";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy";
@customElement("energy-electricity-view-strategy")
export class EnergyElectricityViewStrategy extends ReactiveElement {
const setupWizard = async (): Promise<LovelaceViewConfig> => {
await import("../cards/energy-setup-wizard-card");
return {
type: "panel",
cards: [
{
type: "custom:energy-setup-wizard-card",
},
],
};
};
@customElement("energy-view-strategy")
export class EnergyViewStrategy extends ReactiveElement {
static async generate(
_config: LovelaceStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const view: LovelaceViewConfig = { cards: [] };
const collectionKey =
_config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY;
let prefs: EnergyPreferences;
const energyCollection = getEnergyDataCollection(hass, {
key: collectionKey,
});
const prefs = energyCollection.prefs;
// No energy sources available
if (
!prefs ||
(prefs.device_consumption.length === 0 &&
prefs.energy_sources.length === 0)
) {
try {
prefs = await getEnergyPreferences(hass);
} catch (err: any) {
if (err.code === "not_found") {
return setupWizard();
}
view.cards!.push({
type: "markdown",
content: `An error occurred while fetching your energy preferences: ${err.message}.`,
});
return view;
}
// No energy sources available, start from scratch
if (
prefs!.device_consumption.length === 0 &&
prefs!.energy_sources.length === 0
) {
return setupWizard();
}
view.type = "sidebar";
const hasGrid = prefs.energy_sources.find(
@@ -43,9 +63,13 @@ export class EnergyElectricityViewStrategy extends ReactiveElement {
const hasSolar = prefs.energy_sources.some(
(source) => source.type === "solar"
);
const hasGas = prefs.energy_sources.some((source) => source.type === "gas");
const hasBattery = prefs.energy_sources.some(
(source) => source.type === "battery"
);
const hasWater = prefs.energy_sources.some(
(source) => source.type === "water"
);
view.cards!.push({
type: "energy-compare",
@@ -70,6 +94,24 @@ export class EnergyElectricityViewStrategy extends ReactiveElement {
});
}
// Only include if we have a gas source.
if (hasGas) {
view.cards!.push({
title: hass.localize("ui.panel.energy.cards.energy_gas_graph_title"),
type: "energy-gas-graph",
collection_key: "energy_dashboard",
});
}
// Only include if we have a water source.
if (hasWater) {
view.cards!.push({
title: hass.localize("ui.panel.energy.cards.energy_water_graph_title"),
type: "energy-water-graph",
collection_key: "energy_dashboard",
});
}
// Only include if we have a grid or battery.
if (hasGrid || hasBattery) {
view.cards!.push({
@@ -80,14 +122,13 @@ export class EnergyElectricityViewStrategy extends ReactiveElement {
});
}
if (hasGrid || hasSolar || hasBattery) {
if (hasGrid || hasSolar || hasGas || hasWater || hasBattery) {
view.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.energy_sources_table_title"
),
type: "energy-sources-table",
collection_key: "energy_dashboard",
types: ["grid", "solar", "battery"],
});
}
@@ -129,6 +170,20 @@ export class EnergyElectricityViewStrategy extends ReactiveElement {
// Only include if we have at least 1 device in the config.
if (prefs.device_consumption.length) {
view.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.energy_devices_detail_graph_title"
),
type: "energy-devices-detail-graph",
collection_key: "energy_dashboard",
});
view.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.energy_devices_graph_title"
),
type: "energy-devices-graph",
collection_key: "energy_dashboard",
});
const showFloorsNAreas = !prefs.device_consumption.some(
(d) => d.included_in_stat
);
@@ -139,20 +194,6 @@ export class EnergyElectricityViewStrategy extends ReactiveElement {
group_by_floor: showFloorsNAreas,
group_by_area: showFloorsNAreas,
});
view.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.energy_devices_graph_title"
),
type: "energy-devices-graph",
collection_key: "energy_dashboard",
});
view.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.energy_devices_detail_graph_title"
),
type: "energy-devices-detail-graph",
collection_key: "energy_dashboard",
});
}
return view;
@@ -161,6 +202,6 @@ export class EnergyElectricityViewStrategy extends ReactiveElement {
declare global {
interface HTMLElementTagNameMap {
"energy-electricity-view-strategy": EnergyElectricityViewStrategy;
"energy-view-strategy": EnergyViewStrategy;
}
}

View File

@@ -1,9 +1,8 @@
import { css, LitElement, nothing, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeDomain } from "../../../common/entity/compute_domain";
import { isNumericFromAttributes } from "../../../common/number/format_number";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import type { LovelaceCardFeature } from "../types";
import type {
LovelaceCardFeatureContext,
BarGaugeCardFeatureConfig,
@@ -18,7 +17,7 @@ export const supportsBarGaugeCardFeature = (
: undefined;
if (!stateObj) return false;
const domain = computeDomain(stateObj.entity_id);
return domain === "sensor" && isNumericFromAttributes(stateObj.attributes);
return domain === "sensor" && stateObj.attributes.unit_of_measurement === "%";
};
@customElement("hui-bar-gauge-card-feature")
@@ -35,11 +34,6 @@ class HuiBarGaugeCardFeature extends LitElement implements LovelaceCardFeature {
};
}
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-bar-gauge-card-feature-editor");
return document.createElement("hui-bar-gauge-card-feature-editor");
}
public setConfig(config: BarGaugeCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
@@ -59,20 +53,8 @@ class HuiBarGaugeCardFeature extends LitElement implements LovelaceCardFeature {
return nothing;
}
const stateObj = this.hass.states[this.context.entity_id];
const min = this._config.min ?? 0;
const max = this._config.max ?? 100;
const value = parseFloat(stateObj.state);
if (isNaN(value) || min >= max) {
return nothing;
}
const percentage = Math.max(
0,
Math.min(100, ((value - min) / (max - min)) * 100)
);
return html`<div style="width: ${percentage}%"></div>
const value = stateObj.state;
return html`<div style="width: ${value}%"></div>
<div class="bar-gauge-background"></div>`;
}

View File

@@ -226,8 +226,6 @@ export interface AreaControlsCardFeatureConfig {
export interface BarGaugeCardFeatureConfig {
type: "bar-gauge";
min?: number;
max?: number;
}
export type LovelaceCardFeaturePosition = "bottom" | "inline";

View File

@@ -203,7 +203,7 @@ function formatTooltip(
countNegative++;
}
}
return `${param.marker} ${filterXSS(param.seriesName!)}: <div style="direction:ltr; display: inline;">${value} ${unit}</div>`;
return `${param.marker} ${filterXSS(param.seriesName!)}: ${value} ${unit}`;
})
.filter(Boolean);
let footer = "";

View File

@@ -135,13 +135,11 @@ export class HuiEnergyDevicesGraphCard
return nothing;
}
const modes = this._getAllowedModes();
return html`
<ha-card>
<div class="card-header">
<span>${this._config.title ? this._config.title : nothing}</span>
${modes.length > 1
${this._getAllowedModes().length > 1
? html`
<ha-icon-button
.path=${this._chartType === "pie"
@@ -168,7 +166,7 @@ export class HuiEnergyDevicesGraphCard
this._chartType,
this._legendData
)}
.height=${`${Math.max(modes.includes("pie") ? 300 : 100, (this._legendData?.length || 0) * 28 + 50)}px`}
.height=${`${Math.max(300, (this._legendData?.length || 0) * 28 + 50)}px`}
.extraComponents=${[PieChart]}
@chart-click=${this._handleChartClick}
@dataset-hidden=${this._datasetHidden}
@@ -187,7 +185,7 @@ export class HuiEnergyDevicesGraphCard
this.hass.locale,
params.value < 0.1 ? { maximumFractionDigits: 3 } : undefined
)} kWh`;
return `${title}${params.marker} ${params.seriesName}: <div style="direction:ltr; display: inline;">${value}</div>`;
return `${title}${params.marker} ${params.seriesName}: ${value}`;
}
private _createOptions = memoizeOne(
@@ -494,7 +492,7 @@ export class HuiEnergyDevicesGraphCard
show: true,
position: "center",
color: computedStyle.getPropertyValue("--secondary-text-color"),
fontSize: computedStyle.getPropertyValue("--ha-font-size-m"),
fontSize: computedStyle.getPropertyValue("--ha-font-size-l"),
lineHeight: 24,
fontWeight: "bold",
formatter: `{a}\n${formatNumber(totalChart, this.hass.locale)} kWh`,

View File

@@ -2,7 +2,6 @@ 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 "../../../../components/ha-svg-icon";
import type { EnergyData } from "../../../../data/energy";
@@ -39,8 +38,6 @@ class HuiEnergySankeyCard
{
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public layout?: string;
@state() private _config?: EnergySankeyCardConfig;
@state() private _data?: EnergyData;
@@ -388,14 +385,7 @@ class HuiEnergySankeyCard
(this._config.layout !== "horizontal" && this._isMobileSize);
return html`
<ha-card
.header=${this._config.title}
class=${classMap({
"is-grid": this.layout === "grid",
"is-panel": this.layout === "panel",
"is-vertical": vertical,
})}
>
<ha-card .header=${this._config.title}>
<div class="card-content">
${hasData
? html`<ha-sankey-chart
@@ -412,9 +402,7 @@ class HuiEnergySankeyCard
}
private _valueFormatter = (value: number) =>
`<div style="direction:ltr; display: inline;">
${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)}
kWh</div>`;
`${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)} kWh`;
protected _groupByFloorAndArea(deviceNodes: Node[]) {
const areas: Record<string, { value: number; devices: Node[] }> = {
@@ -520,18 +508,17 @@ class HuiEnergySankeyCard
}
static styles = css`
:host {
display: block;
height: calc(
var(--row-size, 8) *
(var(--row-height, 50px) + var(--row-gap, 0px)) - var(--row-gap, 0px)
);
}
ha-card {
height: 400px;
height: 100%;
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;

View File

@@ -1,739 +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 "../../../../components/ha-svg-icon";
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
import { getEnergyDataCollection } from "../../../../data/energy";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard, LovelaceGridOptions } from "../../types";
import type { PowerSankeyCardConfig } 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 { formatNumber } from "../../../../common/number/format_number";
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
const DEFAULT_CONFIG: Partial<PowerSankeyCardConfig> = {
group_by_floor: true,
group_by_area: true,
};
interface PowerData {
solar: number;
from_grid: number;
to_grid: number;
from_battery: number;
to_battery: number;
grid_to_battery: number;
battery_to_grid: number;
solar_to_battery: number;
solar_to_grid: number;
used_solar: number;
used_grid: number;
used_battery: number;
used_total: number;
}
@customElement("hui-power-sankey-card")
class HuiPowerSankeyCard
extends SubscribeMixin(MobileAwareMixin(LitElement))
implements LovelaceCard
{
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public layout?: string;
@state() private _config?: PowerSankeyCardConfig;
@state() private _data?: EnergyData;
private _entities = new Set<string>();
protected hassSubscribeRequiredHostProps = ["_config"];
public setConfig(config: PowerSankeyCardConfig): 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;
}
// 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;
}
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 powerData = this._computePowerData(prefs);
const computedStyle = getComputedStyle(this);
const nodes: Node[] = [];
const links: Link[] = [];
// Create home node
const homeNode: Node = {
id: "home",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_distribution.home"
),
value: Math.max(0, powerData.used_total),
color: computedStyle.getPropertyValue("--primary-color").trim(),
index: 1,
};
nodes.push(homeNode);
// Add battery source and sink if available
if (powerData.from_battery > 0) {
nodes.push({
id: "battery",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_distribution.battery"
),
value: powerData.from_battery,
color: computedStyle
.getPropertyValue("--energy-battery-out-color")
.trim(),
index: 0,
});
links.push({
source: "battery",
target: "home",
});
}
if (powerData.to_battery > 0) {
nodes.push({
id: "battery_in",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_distribution.battery"
),
value: powerData.to_battery,
color: computedStyle
.getPropertyValue("--energy-battery-in-color")
.trim(),
index: 1,
});
if (powerData.grid_to_battery > 0) {
links.push({
source: "grid",
target: "battery_in",
});
}
if (powerData.solar_to_battery > 0) {
links.push({
source: "solar",
target: "battery_in",
});
}
}
// Add grid source if available
if (powerData.from_grid > 0) {
nodes.push({
id: "grid",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_distribution.grid"
),
value: powerData.from_grid,
color: computedStyle
.getPropertyValue("--energy-grid-consumption-color")
.trim(),
index: 0,
});
links.push({
source: "grid",
target: "home",
});
}
// Add solar if available
if (powerData.solar > 0) {
nodes.push({
id: "solar",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_distribution.solar"
),
value: powerData.solar,
color: computedStyle.getPropertyValue("--energy-solar-color").trim(),
index: 0,
});
links.push({
source: "solar",
target: "home",
});
}
// Add grid return if available
if (powerData.to_grid > 0) {
nodes.push({
id: "grid_return",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_distribution.grid"
),
value: powerData.to_grid,
color: computedStyle
.getPropertyValue("--energy-grid-return-color")
.trim(),
index: 2,
});
if (powerData.battery_to_grid > 0) {
links.push({
source: "battery",
target: "grid_return",
});
}
if (powerData.solar_to_grid > 0) {
links.push({
source: "solar",
target: "grid_return",
});
}
}
let untrackedConsumption = homeNode.value;
const deviceNodes: Node[] = [];
const parentLinks: Record<string, string> = {};
prefs.device_consumption.forEach((device, idx) => {
if (!device.stat_rate) {
return;
}
const value = this._getCurrentPower(device.stat_rate);
if (value < 0.01) {
return;
}
const node = {
id: device.stat_rate,
label: device.name || this._getEntityLabel(device.stat_rate),
value,
color: getGraphColorByIndex(idx, computedStyle),
index: 4,
parent: device.included_in_stat,
};
if (node.parent) {
parentLinks[node.id] = node.parent;
links.push({
source: node.parent,
target: node.id,
});
} else {
untrackedConsumption -= value;
}
deviceNodes.push(node);
});
const devicesWithoutParent = deviceNodes.filter(
(node) => !parentLinks[node.id]
);
const { group_by_area, group_by_floor } = 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) {
// link "no_floor" areas to home
floorNodeId = "home";
} else {
nodes.push({
id: floorNodeId,
label: this.hass.floors[floorId].name,
value: floors[floorId].value,
index: 2,
color: computedStyle.getPropertyValue("--primary-color").trim(),
});
links.push({
source: "home",
target: floorNodeId,
});
}
floors[floorId].areas.forEach((areaId) => {
let targetNodeId: string;
if (areaId === "no_area" || !group_by_area) {
// If group_by_area is false, link devices to floor or home
targetNodeId = floorNodeId;
} else {
// Create area node and link it to floor
const areaNodeId = `area_${areaId}`;
nodes.push({
id: areaNodeId,
label: this.hass.areas[areaId]?.name || areaId,
value: areas[areaId].value,
index: 3,
color: computedStyle.getPropertyValue("--primary-color").trim(),
});
links.push({
source: floorNodeId,
target: areaNodeId,
value: areas[areaId].value,
});
targetNodeId = areaNodeId;
}
// Link devices to the appropriate target (area, floor, or home)
areas[areaId].devices.forEach((device) => {
links.push({
source: targetNodeId,
target: device.id,
value: device.value,
});
});
});
});
} else {
devicesWithoutParent.forEach((deviceNode) => {
links.push({
source: "home",
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
if (untrackedConsumption > 0) {
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: "home",
target: "untracked",
value: untrackedConsumption,
});
}
const hasData = nodes.some((node) => node.value > 0);
const vertical =
this._config.layout === "vertical" ||
(this._config.layout !== "horizontal" && this._isMobileSize);
return html`
<ha-card
.header=${this._config.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}
></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;">
${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)}
kW</div>`;
/**
* Compute real-time power data from current entity states.
* Similar to computeConsumptionData but for instantaneous power.
*/
private _computePowerData(prefs: EnergyPreferences): PowerData {
// Clear tracked entities and rebuild the set
this._entities.clear();
let solar = 0;
let from_grid = 0;
let to_grid = 0;
let from_battery = 0;
let to_battery = 0;
// Collect solar power
prefs.energy_sources
.filter((source) => source.type === "solar")
.forEach((source) => {
if (source.type === "solar" && source.stat_rate) {
const value = this._getCurrentPower(source.stat_rate);
if (value > 0) {
solar += value;
}
}
});
// Collect grid power (positive = import, negative = export)
prefs.energy_sources
.filter((source) => source.type === "grid" && source.power)
.forEach((source) => {
if (source.type === "grid" && source.power) {
source.power.forEach((powerSource) => {
const value = this._getCurrentPower(powerSource.stat_rate);
if (value > 0) {
from_grid += value;
} else if (value < 0) {
to_grid += Math.abs(value);
}
});
}
});
// Collect battery power (positive = discharge, negative = charge)
prefs.energy_sources
.filter((source) => source.type === "battery")
.forEach((source) => {
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);
}
}
});
// Calculate total consumption
const used_total = from_grid + solar + from_battery - to_grid - to_battery;
// Determine power routing using priority logic
// Priority: Solar -> Battery_In, Solar -> Grid_Out, Battery_Out -> Grid_Out,
// Grid_In -> Battery_In, Solar -> Consumption, Battery_Out -> Consumption, Grid_In -> Consumption
let solar_remaining = solar;
let grid_remaining = from_grid;
let battery_remaining = from_battery;
let to_battery_remaining = to_battery;
let to_grid_remaining = to_grid;
let used_total_remaining = Math.max(used_total, 0);
let grid_to_battery = 0;
let battery_to_grid = 0;
let solar_to_battery = 0;
let solar_to_grid = 0;
let used_solar = 0;
let used_battery = 0;
let used_grid = 0;
// Handle excess grid input to battery first
const excess_grid_in_after_consumption = Math.max(
0,
Math.min(to_battery_remaining, grid_remaining - used_total_remaining)
);
grid_to_battery += excess_grid_in_after_consumption;
to_battery_remaining -= excess_grid_in_after_consumption;
grid_remaining -= excess_grid_in_after_consumption;
// Solar -> Battery_In
solar_to_battery = Math.min(solar_remaining, to_battery_remaining);
to_battery_remaining -= solar_to_battery;
solar_remaining -= solar_to_battery;
// Solar -> Grid_Out
solar_to_grid = Math.min(solar_remaining, to_grid_remaining);
to_grid_remaining -= solar_to_grid;
solar_remaining -= solar_to_grid;
// Battery_Out -> Grid_Out
battery_to_grid = Math.min(battery_remaining, to_grid_remaining);
battery_remaining -= battery_to_grid;
to_grid_remaining -= battery_to_grid;
// Grid_In -> Battery_In (second pass)
const grid_to_battery_2 = Math.min(grid_remaining, to_battery_remaining);
grid_to_battery += grid_to_battery_2;
grid_remaining -= grid_to_battery_2;
to_battery_remaining -= grid_to_battery_2;
// Solar -> Consumption
used_solar = Math.min(used_total_remaining, solar_remaining);
used_total_remaining -= used_solar;
solar_remaining -= used_solar;
// Battery_Out -> Consumption
used_battery = Math.min(battery_remaining, used_total_remaining);
battery_remaining -= used_battery;
used_total_remaining -= used_battery;
// Grid_In -> Consumption
used_grid = Math.min(used_total_remaining, grid_remaining);
grid_remaining -= used_grid;
used_total_remaining -= used_grid;
return {
solar,
from_grid,
to_grid,
from_battery,
to_battery,
grid_to_battery,
battery_to_grid,
solar_to_battery,
solar_to_grid,
used_solar,
used_grid,
used_battery,
used_total: Math.max(0, used_total),
};
}
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],
};
}
// see if the area has a floor
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 };
}
/**
* Organizes device nodes into hierarchical sections based on parent-child relationships.
*/
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) {
// Top-level parents (have children but no parents themselves)
parentSection.push(deviceNode);
} else {
childSection.push(deviceNode);
}
});
// Filter out links where parent is already in current parent section
Object.entries(parentLinks).forEach(([child, parent]) => {
if (!parentSection.some((node) => node.id === parent)) {
remainingLinks[child] = parent;
}
});
if (parentSection.length > 0) {
// Recursively process child section with remaining links
return [
parentSection,
...this._getDeviceSections(remainingLinks, childSection),
];
}
// Base case: no more parent-child relationships to process
return [deviceNodes];
}
/**
* Get current power value from entity state, normalized to kW
* @param entityId - The entity ID to get power value from
* @returns Power value in kW, or 0 if entity not found or invalid
*/
private _getCurrentPower(entityId: string): number {
// Track this entity for state change detection
this._entities.add(entityId);
const stateObj = this.hass.states[entityId];
if (!stateObj) {
return 0;
}
const value = parseFloat(stateObj.state);
if (isNaN(value)) {
return 0;
}
// Normalize to kW based on unit of measurement (case-sensitive)
// Supported units: GW, kW, MW, mW, TW, W
const unit = stateObj.attributes.unit_of_measurement;
switch (unit) {
case "W":
return value / 1000;
case "mW":
return value / 1000000;
case "MW":
return value * 1000;
case "GW":
return value * 1000000;
case "TW":
return value * 1000000000;
default:
// Assume kW if no unit or unit is kW
return value;
}
}
/**
* Get entity label (friendly name or entity ID)
* @param entityId - The entity ID to get label for
* @returns Friendly name if available, otherwise the entity ID
*/
private _getEntityLabel(entityId: string): string {
const stateObj = this.hass.states[entityId];
if (!stateObj) {
return entityId;
}
return stateObj.attributes.friendly_name || entityId;
}
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-power-sankey-card": HuiPowerSankeyCard;
}
}

View File

@@ -21,7 +21,6 @@ import { hasConfigChanged } from "../../common/has-changed";
import { getCommonOptions, fillLineGaps } from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts/echarts";
import { hex2rgb } from "../../../../common/color/convert-color";
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
@customElement("hui-power-sources-graph-card")
export class HuiPowerSourcesGraphCard
@@ -34,8 +33,6 @@ export class HuiPowerSourcesGraphCard
@state() private _chartData: LineSeriesOption[] = [];
@state() private _legendData?: CustomLegendOption["data"];
@state() private _start = startOfToday();
@state() private _end = endOfToday();
@@ -94,8 +91,7 @@ export class HuiPowerSourcesGraphCard
this.hass.locale,
this.hass.config,
this._compareStart,
this._compareEnd,
this._legendData
this._compareEnd
)}
></ha-chart-base>
${!this._chartData.some((dataset) => dataset.data!.length)
@@ -119,10 +115,9 @@ export class HuiPowerSourcesGraphCard
locale: FrontendLocaleData,
config: HassConfig,
compareStart?: Date,
compareEnd?: Date,
legendData?: CustomLegendOption["data"]
): ECOption => ({
...getCommonOptions(
compareEnd?: Date
): ECOption =>
getCommonOptions(
start,
end,
locale,
@@ -130,18 +125,11 @@ export class HuiPowerSourcesGraphCard
"kW",
compareStart,
compareEnd
),
legend: {
show: true,
type: "custom",
data: legendData,
},
})
)
);
private async _getStatistics(energyData: EnergyData): Promise<void> {
const datasets: LineSeriesOption[] = [];
this._legendData = [];
const statIds = {
solar: {
@@ -250,15 +238,6 @@ export class HuiPowerSourcesGraphCard
z: 4 - keyIndex, // draw in reverse order but above positive series
});
}
this._legendData!.push({
id: key,
secondaryIds: key !== "solar" ? [`${key}-negative`] : [],
name: statIds[key].name,
itemStyle: {
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`,
borderColor: colorHex,
},
});
}
});
@@ -289,23 +268,11 @@ export class HuiPowerSourcesGraphCard
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.power_graph.usage"
),
color: computedStyles.getPropertyValue("--primary-text-color"),
lineStyle: {
type: [7, 2],
width: 1.5,
},
color: computedStyles.getPropertyValue("--primary-color"),
lineStyle: { width: 2 },
data: usageData,
z: 5,
});
this._legendData!.push({
id: "usage",
name: this.hass.localize(
"ui.panel.lovelace.cards.energy.power_graph.usage"
),
itemStyle: {
color: computedStyles.getPropertyValue("--primary-text-color"),
},
});
}
private _processData(stats: StatisticValue[][]) {

View File

@@ -150,6 +150,11 @@ export interface EnergyCardBaseConfig extends LovelaceCardConfig {
collection_key?: string;
}
export interface EnergySummaryCardConfig extends EnergyCardBaseConfig {
type: "energy-summary";
title?: string;
}
export interface EnergyDistributionCardConfig extends EnergyCardBaseConfig {
type: "energy-distribution";
title?: string;
@@ -231,14 +236,6 @@ export interface PowerSourcesGraphCardConfig extends EnergyCardBaseConfig {
title?: string;
}
export interface PowerSankeyCardConfig extends EnergyCardBaseConfig {
type: "power-sankey";
title?: string;
layout?: "vertical" | "horizontal" | "auto";
group_by_floor?: boolean;
group_by_area?: boolean;
}
export interface EntityFilterCardConfig extends LovelaceCardConfig {
type: "entity-filter";
entities: (EntityFilterEntityConfig | string)[];

View File

@@ -68,7 +68,6 @@ const LAZY_LOAD_TYPES = {
"energy-sankey": () => import("../cards/energy/hui-energy-sankey-card"),
"power-sources-graph": () =>
import("../cards/energy/hui-power-sources-graph-card"),
"power-sankey": () => import("../cards/energy/hui-power-sankey-card"),
"entity-filter": () => import("../cards/hui-entity-filter-card"),
error: () => import("../cards/hui-error-card"),
"home-summary": () => import("../cards/hui-home-summary-card"),

View File

@@ -21,10 +21,7 @@ import {
} from "../../../../data/lovelace_custom_cards";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import {
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../../resources/styles";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import "../../badges/hui-badge";
@@ -398,7 +395,6 @@ export class HuiDialogEditBadge
static get styles(): CSSResultGroup {
return [
haStyleDialog,
haStyleDialogFixedTop,
css`
:host {
--code-mirror-max-height: calc(100vh - 176px);
@@ -407,6 +403,8 @@ export class HuiDialogEditBadge
ha-dialog {
--mdc-dialog-max-width: 100px;
--dialog-z-index: 6;
--dialog-surface-position: fixed;
--dialog-surface-top: 40px;
--mdc-dialog-max-width: 90vw;
--dialog-content-padding: 24px 12px;
}

View File

@@ -21,10 +21,7 @@ import {
} from "../../../../data/lovelace_custom_cards";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import {
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../../resources/styles";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
@@ -374,7 +371,6 @@ export class HuiDialogEditCard
static get styles(): CSSResultGroup {
return [
haStyleDialog,
haStyleDialogFixedTop,
css`
:host {
--code-mirror-max-height: calc(100vh - 176px);
@@ -383,6 +379,8 @@ export class HuiDialogEditCard
ha-dialog {
--mdc-dialog-max-width: 100px;
--dialog-z-index: 6;
--dialog-surface-position: fixed;
--dialog-surface-top: 40px;
--mdc-dialog-max-width: 90vw;
--dialog-content-padding: 24px 12px;
}

View File

@@ -1,87 +0,0 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type {
BarGaugeCardFeatureConfig,
LovelaceCardFeatureContext,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
@customElement("hui-bar-gauge-card-feature-editor")
export class HuiBarGaugeCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: BarGaugeCardFeatureConfig;
public setConfig(config: BarGaugeCardFeatureConfig): void {
this._config = config;
}
private _schema = memoizeOne(
() =>
[
{
name: "min",
default: 0,
selector: {
number: {
mode: "box",
},
},
},
{
name: "max",
default: 100,
selector: {
number: {
mode: "box",
},
},
},
] as const
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const schema = this._schema();
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) =>
this.hass!.localize(
`ui.panel.lovelace.editor.features.types.bar-gauge.${schema.name}`
);
}
declare global {
interface HTMLElementTagNameMap {
"hui-bar-gauge-card-feature-editor": HuiBarGaugeCardFeatureEditor;
}
}

View File

@@ -123,7 +123,6 @@ type UiFeatureTypes = (typeof UI_FEATURE_TYPES)[number];
const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
"alarm-modes",
"area-controls",
"bar-gauge",
"button",
"climate-fan-modes",
"climate-hvac-modes",

View File

@@ -17,10 +17,7 @@ import "../../../../../components/ha-dialog-header";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-list-item";
import type { LovelaceStrategyConfig } from "../../../../../data/lovelace/config/strategy";
import {
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../../../resources/styles";
import { haStyleDialog } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { showSaveSuccessToast } from "../../../../../util/toast-saved-success";
import { cleanLegacyStrategyConfig } from "../../../strategies/legacy-strategy";
@@ -222,10 +219,11 @@ class DialogDashboardStrategyEditor extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-dialog {
--dialog-content-padding: 0 24px;
--dialog-surface-position: fixed;
--dialog-surface-top: 40px;
--mdc-dialog-min-width: min(640px, calc(100% - 32px));
--mdc-dialog-max-width: min(640px, calc(100% - 32px));
--mdc-dialog-max-height: calc(100% - 80px);

View File

@@ -30,10 +30,7 @@ import {
} from "../../../../data/lovelace/config/view";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import {
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../../resources/styles";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { Lovelace } from "../../types";
import { addSection, deleteSection, moveSection } from "../config-util";
@@ -421,8 +418,19 @@ export class HuiDialogEditSection
static get styles(): CSSResultGroup {
return [
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-dialog {
/* Set the top top of the dialog to a fixed position, so it doesnt jump when the content changes size */
--vertical-align-dialog: flex-start;
--dialog-surface-margin-top: 40px;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
/* When in fullscreen dialog should be attached to top */
ha-dialog {
--dialog-surface-margin-top: 0px;
}
}
ha-dialog.yaml-mode {
--dialog-content-padding: 0;
}

View File

@@ -36,10 +36,7 @@ import {
showAlertDialog,
showConfirmationDialog,
} from "../../../../dialogs/generic/show-dialog-box";
import {
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../../resources/styles";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../components/hui-entity-editor";
import type { Lovelace } from "../../types";
@@ -634,8 +631,19 @@ export class HuiDialogEditView extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-dialog {
/* Set the top top of the dialog to a fixed position, so it doesnt jump when the content changes size */
--vertical-align-dialog: flex-start;
--dialog-surface-margin-top: 40px;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
/* When in fullscreen dialog should be attached to top */
ha-dialog {
--dialog-surface-margin-top: 0px;
}
}
ha-dialog.yaml-mode {
--dialog-content-padding: 0;
}

View File

@@ -16,10 +16,7 @@ import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import type { LovelaceViewHeaderConfig } from "../../../../data/lovelace/config/view";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import {
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../../resources/styles";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "./hui-view-header-settings-editor";
import type { EditViewHeaderDialogParams } from "./show-edit-view-header-dialog";
@@ -204,8 +201,19 @@ export class HuiDialogEditViewHeader extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-dialog {
/* Set the top top of the dialog to a fixed position, so it doesnt jump when the content changes size */
--vertical-align-dialog: flex-start;
--dialog-surface-margin-top: 40px;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
/* When in fullscreen dialog should be attached to top */
ha-dialog {
--dialog-surface-margin-top: 0px;
}
}
ha-dialog.yaml-mode {
--dialog-content-padding: 0;
}

View File

@@ -38,10 +38,7 @@ const STRATEGIES: Record<LovelaceStrategyConfigType, Record<string, any>> = {
view: {
"original-states": () =>
import("./original-states/original-states-view-strategy"),
"energy-overview": () =>
import("../../energy/strategies/energy-overview-view-strategy"),
"energy-electricity": () =>
import("../../energy/strategies/energy-electricity-view-strategy"),
energy: () => import("../../energy/strategies/energy-view-strategy"),
map: () => import("./map/map-view-strategy"),
iframe: () => import("./iframe/iframe-view-strategy"),
area: () => import("./areas/area-view-strategy"),

View File

@@ -365,7 +365,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
@media (max-width: 600px) {
:host {
--column-gap: var(--ha-view-sections-narrow-column-gap, var(--row-gap));
--column-gap: var(--row-gap);
}
}

View File

@@ -142,11 +142,6 @@ export const haStyleDialog = css`
--mdc-dialog-max-width: 600px;
--mdc-dialog-max-width: min(600px, 95vw);
--justify-action-buttons: space-between;
--dialog-container-padding: var(--safe-area-inset-top, var(--ha-space-0))
var(--safe-area-inset-right, var(--ha-space-0))
var(--safe-area-inset-bottom, var(--ha-space-0))
var(--safe-area-inset-left, var(--ha-space-0));
--dialog-surface-padding: var(--ha-space-0);
}
ha-dialog .form {
@@ -166,11 +161,9 @@ export const haStyleDialog = css`
--mdc-dialog-min-height: 100svh;
--mdc-dialog-max-height: 100vh;
--mdc-dialog-max-height: 100svh;
--dialog-container-padding: var(--ha-space-0);
--dialog-surface-padding: var(--safe-area-inset-top, var(--ha-space-0))
var(--safe-area-inset-right, var(--ha-space-0))
var(--safe-area-inset-bottom, var(--ha-space-0))
var(--safe-area-inset-left, var(--ha-space-0));
--dialog-surface-padding: var(--safe-area-inset-top)
var(--safe-area-inset-right) var(--safe-area-inset-bottom)
var(--safe-area-inset-left);
--vertical-align-dialog: flex-end;
--ha-dialog-border-radius: var(--ha-border-radius-square);
}
@@ -180,49 +173,6 @@ export const haStyleDialog = css`
}
`;
export const haStyleDialogFixedTop = css`
ha-dialog {
/* Pin dialog to top so it doesn't jump when content changes size */
--vertical-align-dialog: flex-start;
--dialog-surface-margin-top: var(--ha-space-10);
--mdc-dialog-min-height: calc(
100vh - var(--dialog-surface-margin-top) - var(--ha-space-2) - var(
--safe-area-inset-y,
var(--ha-space-0)
)
);
--mdc-dialog-min-height: calc(
100svh - var(--dialog-surface-margin-top) - var(--ha-space-2) - var(
--safe-area-inset-y,
var(--ha-space-0)
)
);
--mdc-dialog-max-height: calc(
100vh - var(--dialog-surface-margin-top) - var(--ha-space-2) - var(
--safe-area-inset-y,
var(--ha-space-0)
)
);
--mdc-dialog-max-height: calc(
100svh - var(--dialog-surface-margin-top) - var(--ha-space-2) - var(
--safe-area-inset-y,
var(--ha-space-0)
)
);
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-dialog {
/* When in fullscreen, dialog should be attached to top */
--dialog-surface-margin-top: var(--ha-space-0);
--mdc-dialog-min-height: 100vh;
--mdc-dialog-min-height: 100svh;
--mdc-dialog-max-height: 100vh;
--mdc-dialog-max-height: 100svh;
}
}
`;
export const haStyleScrollbar = css`
.ha-scrollbar::-webkit-scrollbar {
width: 0.4rem;

View File

@@ -32,9 +32,6 @@ export const mainStyles = css`
--safe-area-inset-bottom: var(--app-safe-area-inset-bottom, env(safe-area-inset-bottom, 0));
--safe-area-inset-left: var(--app-safe-area-inset-left, env(safe-area-inset-left, 0));
--safe-area-inset-right: var(--app-safe-area-inset-right, env(safe-area-inset-right, 0));
--safe-area-inset-y: calc(var(--safe-area-inset-top, 0px) + var(--safe-area-inset-bottom, 0px));
--safe-area-inset-x: calc(var(--safe-area-inset-left, 0px) + var(--safe-area-inset-right, 0px));
}
`;

View File

@@ -1,4 +1,5 @@
import type { PropertyValues } from "lit";
import { tinykeys } from "tinykeys";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { mainWindow } from "../common/dom/get_main_window";
@@ -11,9 +12,9 @@ import type { Constructor, HomeAssistant } from "../types";
import { storeState } from "../util/ha-pref-storage";
import { showToast } from "../util/toast";
import type { HassElement } from "./hass-element";
import { ShortcutManager } from "../common/keyboard/shortcuts";
import { extractSearchParamsObject } from "../common/url/search-params";
import { showVoiceCommandDialog } from "../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
import { canOverrideAlphanumericInput } from "../common/dom/can-override-input";
import { showShortcutsDialog } from "../dialogs/shortcuts/show-shortcuts-dialog";
import type { Redirects } from "../panels/my/ha-panel-my";
@@ -61,22 +62,21 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
}
private _registerShortcut() {
const shortcutManager = new ShortcutManager();
shortcutManager.add({
tinykeys(window, {
// Those are for latin keyboards that have e, c, m keys
e: { handler: (ev) => this._showQuickBar(ev) },
c: { handler: (ev) => this._showQuickBar(ev, QuickBarMode.Command) },
m: { handler: (ev) => this._createMyLink(ev) },
a: { handler: (ev) => this._showVoiceCommandDialog(ev) },
d: { handler: (ev) => this._showQuickBar(ev, QuickBarMode.Device) },
e: (ev) => this._showQuickBar(ev),
c: (ev) => this._showQuickBar(ev, QuickBarMode.Command),
m: (ev) => this._createMyLink(ev),
a: (ev) => this._showVoiceCommandDialog(ev),
d: (ev) => this._showQuickBar(ev, QuickBarMode.Device),
// Workaround see https://github.com/jamiebuilds/tinykeys/issues/130
"Shift+?": { handler: (ev) => this._showShortcutDialog(ev) },
"Shift+?": (ev) => this._showShortcutDialog(ev),
// Those are fallbacks for non-latin keyboards that don't have e, c, m keys (qwerty-based shortcuts)
KeyE: { handler: (ev) => this._showQuickBar(ev) },
KeyC: { handler: (ev) => this._showQuickBar(ev, QuickBarMode.Command) },
KeyM: { handler: (ev) => this._createMyLink(ev) },
KeyA: { handler: (ev) => this._showVoiceCommandDialog(ev) },
KeyD: { handler: (ev) => this._showQuickBar(ev, QuickBarMode.Device) },
KeyE: (ev) => this._showQuickBar(ev),
KeyC: (ev) => this._showQuickBar(ev, QuickBarMode.Command),
KeyM: (ev) => this._createMyLink(ev),
KeyA: (ev) => this._showVoiceCommandDialog(ev),
KeyD: (ev) => this._showQuickBar(ev, QuickBarMode.Device),
});
}
@@ -87,6 +87,7 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
private _showVoiceCommandDialog(e: KeyboardEvent) {
if (
!this.hass?.enableShortcuts ||
!canOverrideAlphanumericInput(e.composedPath()) ||
!this._conversation(this.hass.config.components)
) {
return;
@@ -104,7 +105,7 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
e: KeyboardEvent,
mode: QuickBarMode = QuickBarMode.Entity
) {
if (!this._canShowQuickBar()) {
if (!this._canShowQuickBar(e)) {
return;
}
@@ -117,7 +118,7 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
}
private _showShortcutDialog(e: KeyboardEvent) {
if (!this._canShowQuickBar()) {
if (!this._canShowQuickBar(e)) {
return;
}
@@ -130,7 +131,10 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
}
private async _createMyLink(e: KeyboardEvent) {
if (!this.hass?.enableShortcuts) {
if (
!this.hass?.enableShortcuts ||
!canOverrideAlphanumericInput(e.composedPath())
) {
return;
}
@@ -189,7 +193,11 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
});
}
private _canShowQuickBar() {
return this.hass?.user?.is_admin && this.hass.enableShortcuts;
private _canShowQuickBar(e: KeyboardEvent) {
return (
this.hass?.user?.is_admin &&
this.hass.enableShortcuts &&
canOverrideAlphanumericInput(e.composedPath())
);
}
};

View File

@@ -1237,8 +1237,7 @@
"times": "times"
},
"summary": "Summary",
"description": "Description",
"location": "Location"
"description": "Description"
},
"views": {
"dayGridMonth": "[%key:ui::panel::lovelace::editor::card::calendar::views::dayGridMonth%]",
@@ -6761,6 +6760,7 @@
},
"analytics": {
"caption": "Analytics",
"header": "Home Assistant analytics",
"description": "Learn how to share data to improve Home Assistant",
"preferences": {
"base": {
@@ -6778,10 +6778,17 @@
"diagnostics": {
"title": "Diagnostics",
"description": "Share crash reports when unexpected errors occur."
},
"snapshots": {
"title": "Devices",
"description": "Generic information about your devices.",
"header": "Device analytics",
"info": "Anonymously share data about your devices to help build the Open Home Foundations device database. This free, open source resource helps users find useful information about smart home devices. Only device-specific details (like model or manufacturer) are shared — never personally identifying information (like the names you assign).",
"learn_more": "Learn more about the device database and how we process your data"
}
},
"need_base_enabled": "You need to enable basic analytics for this option to be available",
"learn_more": "How we process your data",
"learn_more": "Learn how we process your data",
"intro": "Share anonymized information from your installation to help make Home Assistant better and help us convince manufacturers to add local control and privacy-focused features.",
"download_device_info": "Preview device analytics"
},
@@ -7167,7 +7174,7 @@
"grid": "Grid",
"solar": "Solar",
"battery": "Battery",
"usage": "Consumption"
"usage": "Used"
},
"energy_compare": {
"info": "You are comparing the period {start} with the period {end}",
@@ -8425,9 +8432,7 @@
"no_compatible_controls": "No compatible controls available for this area"
},
"bar-gauge": {
"label": "Bar gauge",
"min": "Minimum value",
"max": "Maximum value"
"label": "Bar gauge"
},
"trend-graph": {
"label": "Trend graph"
@@ -9429,11 +9434,6 @@
}
},
"energy": {
"overview": {
"electricity": "Electricity",
"gas": "Gas",
"water": "Water"
},
"download_data": "[%key:ui::panel::history::download_data%]",
"configure": "[%key:ui::dialogs::quick-bar::commands::navigation::energy%]",
"setup": {
@@ -9458,9 +9458,7 @@
"energy_sources_table_title": "Sources",
"energy_devices_graph_title": "Individual devices total usage",
"energy_devices_detail_graph_title": "Individual devices detail usage",
"energy_sankey_title": "Energy flow",
"energy_top_consumers_title": "Top consumers",
"power_sankey_title": "Current power flow"
"energy_sankey_title": "Energy flow"
}
},
"history": {

View File

@@ -8869,19 +8869,19 @@ __metadata:
languageName: node
linkType: hard
"glob@npm:11.1.0":
version: 11.1.0
resolution: "glob@npm:11.1.0"
"glob@npm:11.0.3":
version: 11.0.3
resolution: "glob@npm:11.0.3"
dependencies:
foreground-child: "npm:^3.3.1"
jackspeak: "npm:^4.1.1"
minimatch: "npm:^10.1.1"
minimatch: "npm:^10.0.3"
minipass: "npm:^7.1.2"
package-json-from-dist: "npm:^1.0.0"
path-scurry: "npm:^2.0.0"
bin:
glob: dist/esm/bin.mjs
checksum: 10/da4501819633daff8822c007bb3f93d5c4d2cbc7b15a8e886660f4497dd251a1fb4f53a85fba1e760b31704eff7164aeb2c7a82db10f9f2c362d12c02fe52cf3
checksum: 10/2ae536c1360c0266b523b2bfa6aadc10144a8b7e08869b088e37ac3c27cd30774f82e4bfb291cde796776e878f9e13200c7ff44010eb7054e00f46f649397893
languageName: node
linkType: hard
@@ -9324,7 +9324,7 @@ __metadata:
fancy-log: "npm:2.0.0"
fs-extra: "npm:11.3.2"
fuse.js: "npm:7.1.0"
glob: "npm:11.1.0"
glob: "npm:11.0.3"
google-timezones-json: "npm:1.2.0"
gulp: "npm:5.0.1"
gulp-brotli: "npm:3.0.0"
@@ -9337,7 +9337,7 @@ __metadata:
husky: "npm:9.1.7"
idb-keyval: "npm:6.2.2"
intl-messageformat: "npm:10.7.18"
js-yaml: "npm:4.1.1"
js-yaml: "npm:4.1.0"
jsdom: "npm:27.1.0"
jszip: "npm:3.10.1"
leaflet: "npm:1.9.4"
@@ -10407,14 +10407,14 @@ __metadata:
languageName: node
linkType: hard
"js-yaml@npm:4.1.1, js-yaml@npm:^4.1.0":
version: 4.1.1
resolution: "js-yaml@npm:4.1.1"
"js-yaml@npm:4.1.0, js-yaml@npm:^4.1.0":
version: 4.1.0
resolution: "js-yaml@npm:4.1.0"
dependencies:
argparse: "npm:^2.0.1"
bin:
js-yaml: bin/js-yaml.js
checksum: 10/a52d0519f0f4ef5b4adc1cde466cb54c50d56e2b4a983b9d5c9c0f2f99462047007a6274d7e95617a21d3c91fde3ee6115536ed70991cd645ba8521058b78f77
checksum: 10/c138a34a3fd0d08ebaf71273ad4465569a483b8a639e0b118ff65698d257c2791d3199e3f303631f2cb98213fa7b5f5d6a4621fd0fff819421b990d30d967140
languageName: node
linkType: hard
@@ -11167,12 +11167,12 @@ __metadata:
languageName: node
linkType: hard
"minimatch@npm:^10.1.1":
version: 10.1.1
resolution: "minimatch@npm:10.1.1"
"minimatch@npm:^10.0.3":
version: 10.0.3
resolution: "minimatch@npm:10.0.3"
dependencies:
"@isaacs/brace-expansion": "npm:^5.0.0"
checksum: 10/110f38921ea527022e90f7a5f43721838ac740d0a0c26881c03b57c261354fb9a0430e40b2c56dfcea2ef3c773768f27210d1106f1f2be19cde3eea93f26f45e
checksum: 10/d5b8b2538b367f2cfd4aeef27539fddeee58d1efb692102b848e4a968a09780a302c530eb5aacfa8c57f7299155fb4b4e85219ad82664dcef5c66f657111d9b8
languageName: node
linkType: hard