Compare commits

..

1 Commits

Author SHA1 Message Date
Petar Petrov 4ae9bb996f Add hide/show cards to the energy dashboard
Add a "Customize energy" dialog on the energy settings page that lets
users hide or show individual cards on the energy dashboard. Visibility
is stored in frontend system data (key "energy") and threaded through
the dashboard strategy, which drops a view when all of its cards are
hidden. A shared card catalog is the single source of truth for the
dialog and the per-view strategies.
2026-06-02 15:24:14 +03:00
30 changed files with 1132 additions and 322 deletions
+1 -1
View File
@@ -180,7 +180,7 @@
"jsdom": "29.1.1",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lint-staged": "17.0.6",
"lint-staged": "17.0.5",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
@@ -1,5 +1,6 @@
import { LitElement, css, html } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../ha-tooltip";
export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
@@ -12,6 +13,7 @@ export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
*
* @attr {"pass"|"fail"|"invalid"|"unknown"} state - The current live-test state. Defaults to `unknown`.
* @attr {string} label - Accessible label announced by assistive technology.
* @attr {string} message - Optional tooltip body shown on hover/focus.
*/
@customElement("ha-automation-row-live-test")
export class HaAutomationRowLiveTest extends LitElement {
@@ -19,6 +21,8 @@ export class HaAutomationRowLiveTest extends LitElement {
@property() public label = "";
@property() public message?: string;
protected render() {
return html`
<div
@@ -27,38 +31,39 @@ export class HaAutomationRowLiveTest extends LitElement {
tabindex="0"
aria-label=${this.label}
></div>
${this.message
? html`<ha-tooltip for="indicator">${this.message}</ha-tooltip>`
: nothing}
`;
}
static styles = css`
:host {
position: absolute;
top: -5px;
inset-inline-end: -6px;
display: inline-block;
}
#indicator {
width: 10px;
height: 10px;
width: 12px;
height: 12px;
border-radius: var(--ha-border-radius-circle);
border: var(--ha-border-width-md) solid;
border: 3px solid;
box-sizing: border-box;
background-color: var(--card-background-color);
box-shadow: 0 0 0 2px var(--card-background-color);
transition: all var(--ha-animation-duration-normal) ease-in-out;
}
:host([state="pass"]) #indicator {
background-color: var(--ha-color-green-60);
border-color: var(--ha-color-green-60);
background-color: var(--ha-color-fill-success-loud-resting);
border-color: var(--ha-color-fill-success-loud-resting);
}
:host([state="fail"]) #indicator {
border-color: var(--ha-color-orange-60);
border-color: var(--ha-color-fill-warning-loud-resting);
}
:host([state="invalid"]) #indicator {
border-color: var(--ha-color-red-60);
border-color: var(--ha-color-fill-danger-loud-resting);
}
:host([state="unknown"]) #indicator {
border-color: var(--ha-color-neutral-60);
border-color: var(--ha-color-fill-neutral-loud-resting);
}
`;
}
@@ -165,7 +165,7 @@ export class HaAutomationRow extends LitElement {
::slotted([slot="leading-icon"]) {
color: var(--ha-color-on-neutral-quiet);
}
:host([building-block]) ::slotted(#condition-icon) {
:host([building-block]) ::slotted([slot="leading-icon"]) {
--mdc-icon-size: var(--ha-space-5);
color: var(--white-color);
transform: rotate(-45deg);
+2 -6
View File
@@ -101,22 +101,18 @@ export class HaSankeyChart extends LitElement {
const value = this.valueFormatter
? this.valueFormatter(data.value)
: data.value;
// Keep numbers and units left-to-right, even in RTL locales.
const formattedValue = html`<div style="direction:ltr; display: inline;">
${value}
</div>`;
if (data.id) {
const node = this.data.nodes.find((n) => n.id === data.id);
return html`<ha-chart-tooltip-marker
.color=${String(params.color ?? "")}
></ha-chart-tooltip-marker>
${node?.label ?? data.id}<br />${formattedValue}`;
${node?.label ?? data.id}<br />${value}`;
}
if (data.source && data.target) {
const source = this.data.nodes.find((n) => n.id === data.source);
const target = this.data.nodes.find((n) => n.id === data.target);
return html`${source?.label ?? data.source}
${target?.label ?? data.target}<br />${formattedValue}`;
${target?.label ?? data.target}<br />${value}`;
}
return null;
};
-11
View File
@@ -485,17 +485,6 @@ export const migrateAutomationTrigger = (
}
delete trigger.platform;
}
if ("options" in trigger) {
if (trigger.options && "behavior" in trigger.options) {
if (trigger.options.behavior === "any") {
trigger.options.behavior = "each";
} else if (trigger.options.behavior === "last") {
trigger.options.behavior = "all";
}
}
}
return trigger;
};
+8
View File
@@ -26,6 +26,13 @@ export interface HomeFrontendSystemData {
shortcuts?: ShortcutItem[];
}
export interface EnergyFrontendSystemData {
// Stable "<view>.<card-type>" keys of energy dashboard cards the user has
// hidden. An absent key or array means nothing is hidden (all cards visible),
// so cards added in the future are shown by default.
hidden_cards?: string[];
}
declare global {
interface FrontendUserData {
core: CoreFrontendUserData;
@@ -34,6 +41,7 @@ declare global {
interface FrontendSystemData {
core: CoreFrontendSystemData;
home: HomeFrontendSystemData;
energy: EnergyFrontendSystemData;
}
}
-2
View File
@@ -139,8 +139,6 @@ export class HassTabsSubpage extends LitElement {
);
public willUpdate(changedProperties: PropertyValues<this>) {
this.toggleAttribute("narrow", this._narrow);
if (changedProperties.has("route")) {
const currentPath = `${this.route.prefix}${this.route.path}`;
this._activeTab = this.tabs.find((tab) =>
@@ -1,5 +1,5 @@
import "@home-assistant/webawesome/dist/components/tag/tag";
import { mdiCheckCircle, mdiHelpCircleOutline } from "@mdi/js";
import { mdiHelpCircleOutline } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
@@ -25,9 +25,7 @@ class SupervisorAppsCardContent extends LitElement {
@property() public stage: AddonStage = "stable";
@property() public state?: AddonState;
@property({ type: Boolean }) public installed = false;
@property() public state: AddonState = null;
@property() public description?: string;
@@ -79,23 +77,13 @@ class SupervisorAppsCardContent extends LitElement {
</div>
</div>
</div>
${this.tags?.length || this.state !== undefined || this.installed
${this.tags?.length || this.state
? html`
<div class="footer">
${this.state !== undefined
? html`<supervisor-apps-state
.state=${this.state || "unknown"}
></supervisor-apps-state>`
: this.installed
? html`<div class="installed">
<ha-svg-icon .path=${mdiCheckCircle}></ha-svg-icon>
<span
>${this.hass.localize(
"ui.panel.config.apps.state.installed"
)}</span
>
</div>`
: html`<span></span>`}
<supervisor-apps-state
.state=${this.state || "unknown"}
></supervisor-apps-state>
${this.tags?.length
? html`<div class="tags">
${this.tags.map(
@@ -171,17 +159,6 @@ class SupervisorAppsCardContent extends LitElement {
display: flex;
gap: var(--ha-space-2);
}
.installed {
display: inline-flex;
align-items: center;
gap: var(--ha-space-2);
color: var(--ha-color-text-secondary);
font-size: var(--ha-font-size-m);
}
.installed ha-svg-icon {
--mdc-icon-size: 16px;
color: var(--ha-color-on-success-normal);
}
`;
}
@@ -1,14 +1,7 @@
import {
mdiAlertDecagramOutline,
mdiArrowUpBoldCircle,
mdiArrowUpBoldCircleOutline,
mdiFlask,
mdiPuzzle,
} from "@mdi/js";
import { mdiArrowUpBoldCircle, mdiPuzzle } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { navigate } from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
@@ -17,7 +10,6 @@ import type { HassioAddonRepository } from "../../../data/hassio/addon";
import type { StoreAddon } from "../../../data/supervisor/store";
import type { HomeAssistant } from "../../../types";
import "./components/supervisor-apps-card-content";
import type { AppTag } from "./components/supervisor-apps-card-content";
import { filterAndSort } from "./components/supervisor-apps-filter";
import { supervisorAppsStyle } from "./resources/supervisor-apps-style";
@@ -62,29 +54,21 @@ export class SupervisorAppsRepositoryEl extends LitElement {
<div class="content">
<h1>${repo.name}</h1>
<div class="card-group">
${addons.map((addon) => {
const tags = this._getAppTags(addon);
return html`
${addons.map(
(addon) => html`
<ha-card
outlined
.addon=${addon}
class=${addon.available ? "" : "not_available"}
@click=${this._addonTapped}
>
<div
class=${classMap({
"card-content": true,
"has-footer": tags.length > 0 || addon.installed,
})}
>
<div class="card-content">
<supervisor-apps-card-content
.hass=${this.hass}
.title=${addon.name}
.stage=${addon.stage}
.description=${addon.description}
.available=${addon.available}
.installed=${addon.installed}
.tags=${tags}
.icon=${addon.installed && addon.update_available
? mdiArrowUpBoldCircle
: mdiPuzzle}
@@ -124,8 +108,8 @@ export class SupervisorAppsRepositoryEl extends LitElement {
></supervisor-apps-card-content>
</div>
</ha-card>
`;
})}
`
)}
</div>
</div>
`;
@@ -135,32 +119,6 @@ export class SupervisorAppsRepositoryEl extends LitElement {
navigate(`/config/app/${ev.currentTarget.addon.slug}/info?store=true`);
}
private _getAppTags(addon: StoreAddon): AppTag[] {
const labels: AppTag[] = [];
if (addon.installed && addon.update_available) {
labels.push({
label: this.hass.localize(
`ui.panel.config.apps.state.update_available`
),
variant: "brand",
iconPath: mdiArrowUpBoldCircleOutline,
});
}
if (addon.stage !== "stable") {
labels.push({
label: this.hass.localize(
`ui.panel.config.apps.dashboard.capability.stages.${addon.stage}`
),
variant: addon.stage === "experimental" ? "warning" : "danger",
iconPath:
addon.stage === "experimental" ? mdiFlask : mdiAlertDecagramOutline,
});
}
return labels;
}
static get styles(): CSSResultGroup {
return [
supervisorAppsStyle,
@@ -169,9 +127,6 @@ export class SupervisorAppsRepositoryEl extends LitElement {
cursor: pointer;
overflow: hidden;
}
.card-content.has-footer {
padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-2);
}
.not_available {
opacity: 0.6;
}
@@ -52,7 +52,6 @@ import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-tooltip";
import type {
AutomationClipboard,
Condition,
@@ -212,27 +211,11 @@ export default class HaAutomationConditionRow extends LitElement {
);
return html`
<div id="condition-icon" class="icon-badge-wrapper" slot="leading-icon">
<ha-condition-icon
.hass=${this.hass}
.condition=${this.condition.condition}
></ha-condition-icon>
${this.optionsInSidebar && this.condition.condition !== "trigger"
? html`<ha-automation-row-live-test
.state=${this._liveTestResult.state}
.label=${this.hass.localize(
`ui.panel.config.automation.editor.conditions.live_test_state.${this._liveTestResult.state}`
)}
></ha-automation-row-live-test>`
: nothing}
</div>
${this.optionsInSidebar &&
this.condition.condition !== "trigger" &&
this._liveTestResult.message
? html`<ha-tooltip for="condition-icon" slot="leading-icon"
>${this._liveTestResult.message}</ha-tooltip
>`
: nothing}
<ha-condition-icon
slot="leading-icon"
.hass=${this.hass}
.condition=${this.condition.condition}
></ha-condition-icon>
<h3 slot="header">
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
@@ -548,7 +531,17 @@ export default class HaAutomationConditionRow extends LitElement {
@click=${this._toggleSidebar}
@toggle-collapsed=${this._toggleCollapse}
>${this._renderRow()}
</ha-automation-row>`
<ha-automation-row-live-test
slot="icons"
.state=${this.condition.condition !== "trigger"
? this._liveTestResult.state
: "unknown"}
.label=${this.hass.localize(
`ui.panel.config.automation.editor.conditions.live_test_state.${this.condition.condition !== "trigger" ? this._liveTestResult.state : "unknown"}`
)}
.message=${this._liveTestResult.message}
></ha-automation-row-live-test
></ha-automation-row>`
: html`
<ha-expansion-panel
left-chevron
-5
View File
@@ -53,11 +53,6 @@ export const rowStyles = css`
position: absolute;
}
.icon-badge-wrapper {
position: relative;
display: inline-flex;
}
.note-indicator {
color: var(--ha-color-on-neutral-normal);
}
@@ -0,0 +1,269 @@
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeKeys } from "../../../../common/translations/localize";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-settings-row";
import "../../../../components/ha-spinner";
import "../../../../components/ha-switch";
import type { HaSwitch } from "../../../../components/ha-switch";
import "../../../../components/ha-tooltip";
import {
fetchFrontendSystemData,
saveFrontendSystemData,
} from "../../../../data/frontend";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import type {
EnergyCardCatalogEntry,
EnergyViewPath,
} from "../../../energy/strategies/energy-cards";
import { ENERGY_CARD_CATALOG } from "../../../energy/strategies/energy-cards";
import type { EnergyCustomiseDialogParams } from "./show-dialog-energy-customise";
const VIEW_GROUPS: { view: EnergyViewPath; labelKey: LocalizeKeys }[] = [
{
view: "overview",
labelKey: "ui.panel.config.energy.customise.groups.overview",
},
{ view: "electricity", labelKey: "ui.panel.config.energy.tabs.electricity" },
{ view: "gas", labelKey: "ui.panel.config.energy.tabs.gas" },
{ view: "water", labelKey: "ui.panel.config.energy.tabs.water" },
{ view: "now", labelKey: "ui.panel.config.energy.customise.groups.now" },
];
@customElement("dialog-energy-customise")
export class DialogEnergyCustomise
extends LitElement
implements HassDialog<EnergyCustomiseDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: EnergyCustomiseDialogParams;
@state() private _open = false;
@state() private _error?: string;
@state() private _submitting = false;
// Working copy of the hidden card keys. A switch that is ON means the card is
// visible, i.e. its key is NOT in this set.
@state() private _hidden?: Set<string>;
public showDialog(params: EnergyCustomiseDialogParams): void {
this._params = params;
this._open = true;
this._loadHidden();
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
this._hidden = undefined;
this._error = undefined;
this._submitting = false;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private async _loadHidden(): Promise<void> {
this._error = undefined;
try {
// The card labels reuse keys from the "energy" translation fragment,
// which is not guaranteed to be loaded on the config page.
const [data] = await Promise.all([
fetchFrontendSystemData(this.hass.connection, "energy"),
this.hass.loadFragmentTranslation("energy"),
]);
this._hidden = new Set(data?.hidden_cards ?? []);
} catch (err: any) {
this._error = err?.message || "Unknown error";
this._hidden = new Set();
}
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-dialog
.open=${this._open}
.headerTitle=${this.hass.localize(
"ui.panel.config.energy.customise.title"
)}
.headerSubtitle=${this.hass.localize(
"ui.panel.config.energy.customise.subtitle"
)}
prevent-scrim-close
@closed=${this._dialogClosed}
>
${!this._hidden
? html`<div class="loading">
<ha-spinner size="large"></ha-spinner>
</div>`
: this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: this._renderGroups()}
<ha-dialog-footer slot="footer">
<ha-button
appearance="plain"
slot="secondaryAction"
@click=${this.closeDialog}
.disabled=${this._submitting}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._submitting || !this._hidden || !!this._error}
>
${this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _renderGroups() {
const prefs = this._params!.preferences;
return VIEW_GROUPS.map((group) => {
const cards = ENERGY_CARD_CATALOG.filter((c) => c.view === group.view);
// Hide the whole group when none of its cards apply to the current config.
if (!cards.some((c) => c.isApplicable(prefs))) {
return nothing;
}
return html`
<ha-expansion-panel
outlined
expanded
.header=${this.hass.localize(group.labelKey)}
>
<div class="cards">
${cards.map((card) => this._renderCardRow(card))}
</div>
</ha-expansion-panel>
`;
});
}
private _renderCardRow(card: EnergyCardCatalogEntry) {
const applicable = card.isApplicable(this._params!.preferences);
const label = this.hass.localize(card.labelKey);
const rowId = `row-${card.key}`;
return html`
<ha-settings-row slim id=${rowId}>
<span slot="heading" class=${applicable ? "" : "disabled"}
>${label}</span
>
<ha-switch
.checked=${applicable && !this._hidden!.has(card.key)}
.disabled=${!applicable}
.ariaLabel=${label}
data-card-key=${card.key}
@change=${this._toggleCard}
></ha-switch>
</ha-settings-row>
${applicable
? nothing
: html`
<ha-tooltip .for=${rowId} placement="top">
${this.hass.localize(
"ui.panel.config.energy.customise.unavailable"
)}
</ha-tooltip>
`}
`;
}
private _toggleCard = (ev: Event): void => {
const target = ev.currentTarget as HaSwitch;
const cardKey = target.dataset.cardKey;
if (!cardKey) {
return;
}
const next = new Set(this._hidden);
if (target.checked) {
next.delete(cardKey);
} else {
next.add(cardKey);
}
this._hidden = next;
};
private async _save(): Promise<void> {
if (!this._hidden) {
return;
}
this._submitting = true;
try {
const hidden = Array.from(this._hidden);
await saveFrontendSystemData(this.hass.connection, "energy", {
hidden_cards: hidden.length ? hidden : undefined,
});
this._params?.saveCallback?.();
this.closeDialog();
} catch (_err) {
showToast(this, {
message: this.hass.localize(
"ui.panel.config.energy.customise.save_failed"
),
duration: 0,
dismissable: true,
});
} finally {
this._submitting = false;
}
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-dialog {
--dialog-content-padding: var(--ha-space-6);
}
.loading {
display: flex;
justify-content: center;
padding: var(--ha-space-6);
}
span.disabled {
color: var(--disabled-text-color);
}
ha-expansion-panel {
display: block;
--expansion-panel-content-padding: 0;
border-radius: var(--ha-border-radius-md);
--ha-card-border-radius: var(--ha-border-radius-md);
}
ha-expansion-panel + ha-expansion-panel {
margin-top: var(--ha-space-2);
}
.cards {
padding: 0 var(--ha-space-3);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-energy-customise": DialogEnergyCustomise;
}
}
@@ -0,0 +1,22 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { EnergyPreferences } from "../../../../data/energy";
export interface EnergyCustomiseDialogParams {
preferences: EnergyPreferences;
// Called after a successful save (e.g. to show a toast on the page).
saveCallback?: () => void;
}
export const loadEnergyCustomiseDialog = () =>
import("./dialog-energy-customise");
export const showEnergyCustomiseDialog = (
element: HTMLElement,
params: EnergyCustomiseDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-energy-customise",
dialogImport: loadEnergyCustomiseDialog,
dialogParams: params,
});
};
+43 -9
View File
@@ -1,5 +1,11 @@
import "../../../layouts/hass-error-screen";
import { mdiDownload, mdiFire, mdiLightningBolt, mdiWater } from "@mdi/js";
import {
mdiDownload,
mdiFire,
mdiLightningBolt,
mdiViewDashboardEdit,
mdiWater,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -31,7 +37,9 @@ import "./components/ha-energy-battery-settings";
import "./components/ha-energy-gas-settings";
import "./components/ha-energy-water-settings";
import { fileDownload } from "../../../util/file_download";
import { showToast } from "../../../util/toast";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import { showEnergyCustomiseDialog } from "./dialogs/show-dialog-energy-customise";
const INITIAL_CONFIG: EnergyPreferences = {
energy_sources: [],
@@ -124,14 +132,22 @@ class HaConfigEnergy extends LitElement {
.route=${this.route}
.tabs=${TABS}
>
<ha-icon-button
slot="toolbar-icon"
.path=${mdiDownload}
.label=${this.hass.localize(
"ui.panel.config.devices.download_diagnostics"
)}
@click=${this._downloadDiagnostics}
></ha-icon-button>
<div slot="toolbar-icon" class="toolbar-icons">
<ha-icon-button
.path=${mdiViewDashboardEdit}
.label=${this.hass.localize(
"ui.panel.config.energy.customise.toolbar_action"
)}
@click=${this._customise}
></ha-icon-button>
<ha-icon-button
.path=${mdiDownload}
.label=${this.hass.localize(
"ui.panel.config.devices.download_diagnostics"
)}
@click=${this._downloadDiagnostics}
></ha-icon-button>
</div>
<ha-alert>
${this.hass.localize("ui.panel.config.energy.new_device_info")}
</ha-alert>
@@ -254,6 +270,20 @@ class HaConfigEnergy extends LitElement {
this._statsMetadata = statsMetadata;
}
private _customise() {
if (!this._preferences) {
return;
}
showEnergyCustomiseDialog(this, {
preferences: this._preferences,
saveCallback: () => {
showToast(this, {
message: this.hass.localize("ui.panel.config.energy.customise.saved"),
});
},
});
}
private async _downloadDiagnostics() {
const data = {
version: this.hass.config.version,
@@ -285,6 +315,10 @@ class HaConfigEnergy extends LitElement {
return [
haStyle,
css`
.toolbar-icons {
display: flex;
align-items: center;
}
.content {
padding: 0 var(--ha-space-5);
max-width: 1040px;
@@ -25,7 +25,6 @@ import {
type ExtEntityRegistryEntry,
} from "../../../../../data/entity/entity_registry";
import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box";
import { OVERRIDE_DEVICE_CLASSES } from "../../../entities/entity-registry-settings-editor";
import "./matter-add-device/matter-add-device-apple-home";
import "./matter-add-device/matter-add-device-existing";
import "./matter-add-device/matter-add-device-generic";
@@ -140,17 +139,15 @@ class DialogMatterAddDevice extends LitElement {
entityIds
);
this._mainEntity = Object.values(entries).find((entry) => {
if (entry.entity_category) return false;
const domain = computeDomain(entry.entity_id);
const deviceClasses = OVERRIDE_DEVICE_CLASSES[domain];
if (!deviceClasses) return false;
const deviceClass = entry.device_class ?? entry.original_device_class;
if (!deviceClass) return false;
return deviceClasses.some(
(classes) => classes.length > 1 && classes.includes(deviceClass)
);
});
const mainEntry = Object.values(entries).find(
(e) => e.original_name === null
);
if (!mainEntry) return;
const domain = computeDomain(mainEntry.entity_id);
if (domain === "cover" || domain === "binary_sensor") {
this._mainEntity = mainEntry;
}
}
private _dialogClosed(): void {
+20 -1
View File
@@ -6,6 +6,8 @@ import "../../components/ha-alert";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
import "../../components/ha-top-app-bar-fixed";
import type { EnergyFrontendSystemData } from "../../data/frontend";
import { fetchFrontendSystemData } from "../../data/frontend";
import type { LovelaceConfig } from "../../data/lovelace/config/types";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, PanelInfo } from "../../types";
@@ -26,6 +28,8 @@ class PanelEnergy extends LitElement {
@state() private _lovelace?: Lovelace;
@state() private _config: EnergyFrontendSystemData = {};
@property({ attribute: false }) public route?: {
path: string;
prefix: string;
@@ -58,10 +62,23 @@ class PanelEnergy extends LitElement {
await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
this.hass.loadFragmentTranslation("energy"),
this._loadSystemData(),
]);
this._loadConfig();
}
private async _loadSystemData() {
try {
const data = await fetchFrontendSystemData(
this.hass.connection,
"energy"
);
this._config = data || {};
} catch (_err) {
this._config = {};
}
}
private async _loadConfig() {
try {
this._error = undefined;
@@ -94,6 +111,7 @@ class PanelEnergy extends LitElement {
this.route?.path === "/now"
? DEFAULT_POWER_COLLECTION_KEY
: undefined,
hidden_cards: this._config.hidden_cards,
},
},
this.hass
@@ -164,7 +182,8 @@ class PanelEnergy extends LitElement {
navigate(`/config/energy/${tab}?historyBack=1`);
}
private _reloadConfig() {
private async _reloadConfig() {
await this._loadSystemData();
this._loadConfig();
}
@@ -0,0 +1,296 @@
import type { LocalizeKeys } from "../../../common/translations/localize";
import type {
EnergyPreferences,
GridSourceTypeEnergyPreference,
} from "../../../data/energy";
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
/** Strategy config shared by the per-view energy strategies. */
export interface EnergyViewStrategyConfig extends LovelaceStrategyConfig {
collection_key?: string;
hidden_cards?: string[];
}
export type EnergyViewPath =
| "overview"
| "electricity"
| "gas"
| "water"
| "now";
// --- Applicability helpers -------------------------------------------------
// These mirror, one-to-one, the conditions the individual view strategies use
// to decide whether to emit a card. The catalog and the strategies must agree
// on what "applicable" means, so the conditions live here and are reused.
export const hasGridSource = (prefs: EnergyPreferences): boolean =>
prefs.energy_sources.some(
(source): source is GridSourceTypeEnergyPreference =>
source.type === "grid" &&
(!!source.stat_energy_from || !!source.stat_energy_to)
);
export const hasReturn = (prefs: EnergyPreferences): boolean =>
prefs.energy_sources.some(
(source) => source.type === "grid" && !!source.stat_energy_to
);
export const hasSolar = (prefs: EnergyPreferences): boolean =>
prefs.energy_sources.some((source) => source.type === "solar");
export const hasBattery = (prefs: EnergyPreferences): boolean =>
prefs.energy_sources.some((source) => source.type === "battery");
export const hasGasSource = (prefs: EnergyPreferences): boolean =>
prefs.energy_sources.some((source) => source.type === "gas");
export const hasWaterSource = (prefs: EnergyPreferences): boolean =>
prefs.energy_sources.some((source) => source.type === "water");
export const hasWaterDevices = (prefs: EnergyPreferences): boolean =>
(prefs.device_consumption_water?.length ?? 0) > 0;
export const hasDeviceConsumption = (prefs: EnergyPreferences): boolean =>
prefs.device_consumption.length > 0;
export const hasPowerSources = (prefs: EnergyPreferences): boolean =>
prefs.energy_sources.some((source) => {
if (source.type === "solar" && source.stat_rate) return true;
if (source.type === "battery" && source.stat_rate) return true;
if (source.type === "grid") {
return !!source.stat_rate || !!source.power_config;
}
return false;
});
export const hasPowerDevices = (prefs: EnergyPreferences): boolean =>
prefs.device_consumption.some((device) => device.stat_rate);
export const hasPowerWaterDevices = (prefs: EnergyPreferences): boolean =>
(prefs.device_consumption_water ?? []).some((device) => device.stat_rate);
// --- Card catalog ----------------------------------------------------------
export interface EnergyCardCatalogEntry {
/** Stable identifier and storage token: `<view>.<cardType>`. */
key: string;
view: EnergyViewPath;
/** Localize key for the label shown in the customise dialog. */
labelKey: LocalizeKeys;
/** Whether this card is emitted for the given preferences. */
isApplicable: (prefs: EnergyPreferences) => boolean;
}
export const energyCardKey = (view: EnergyViewPath, cardType: string): string =>
`${view}.${cardType}`;
const entry = (
view: EnergyViewPath,
cardType: string,
labelKey: LocalizeKeys,
isApplicable: (prefs: EnergyPreferences) => boolean
): EnergyCardCatalogEntry => ({
key: energyCardKey(view, cardType),
view,
labelKey,
isApplicable,
});
export const ENERGY_CARD_CATALOG: readonly EnergyCardCatalogEntry[] = [
// --- Overview ---
entry(
"overview",
"energy-distribution",
"ui.panel.energy.cards.energy_distribution_title",
(p) => hasGridSource(p) || hasBattery(p) || hasSolar(p)
),
entry(
"overview",
"energy-sources-table",
"ui.panel.energy.cards.energy_sources_table_title",
(p) => p.energy_sources.length > 0
),
entry(
"overview",
"power-sources-graph",
"ui.panel.energy.cards.power_sources_graph_title",
(p) => hasPowerSources(p)
),
entry(
"overview",
"energy-usage-graph",
"ui.panel.energy.cards.energy_usage_graph_title",
(p) => hasGridSource(p) || hasBattery(p)
),
entry(
"overview",
"energy-gas-graph",
"ui.panel.energy.cards.energy_gas_graph_title",
(p) => hasGasSource(p)
),
// One toggle gates the water row, which renders energy-water-graph (sources)
// or, with only water devices, water-sankey.
entry(
"overview",
"energy-water-graph",
"ui.panel.energy.cards.energy_water_graph_title",
(p) => hasWaterSource(p) || hasWaterDevices(p)
),
// --- Electricity ---
entry(
"electricity",
"energy-distribution",
"ui.panel.energy.cards.energy_distribution_title",
(p) => hasGridSource(p) || hasBattery(p) || hasSolar(p)
),
entry(
"electricity",
"energy-grid-balance",
"ui.panel.energy.cards.energy_grid_balance_title",
(p) => hasGridSource(p) && hasReturn(p)
),
entry(
"electricity",
"energy-grid-neutrality-gauge",
"ui.panel.energy.cards.energy_grid_neutrality_gauge_title",
(p) => hasReturn(p)
),
entry(
"electricity",
"energy-solar-consumed-gauge",
"ui.panel.energy.cards.energy_solar_consumed_gauge_title",
(p) => hasSolar(p) && hasReturn(p)
),
entry(
"electricity",
"energy-self-sufficiency-gauge",
"ui.panel.energy.cards.energy_self_sufficiency_gauge_title",
(p) => hasSolar(p) && hasGridSource(p)
),
entry(
"electricity",
"energy-carbon-consumed-gauge",
"ui.panel.energy.cards.energy_carbon_consumed_gauge_title",
(p) => hasGridSource(p)
),
entry(
"electricity",
"energy-usage-graph",
"ui.panel.energy.cards.energy_usage_graph_title",
(p) => hasGridSource(p) || hasBattery(p)
),
entry(
"electricity",
"energy-solar-graph",
"ui.panel.energy.cards.energy_solar_graph_title",
(p) => hasSolar(p)
),
entry(
"electricity",
"energy-sources-table",
"ui.panel.energy.cards.energy_sources_table_title",
(p) => hasGridSource(p) || hasSolar(p) || hasBattery(p)
),
entry(
"electricity",
"energy-devices-detail-graph",
"ui.panel.energy.cards.energy_devices_detail_graph_title",
(p) => hasDeviceConsumption(p)
),
entry(
"electricity",
"energy-devices-graph",
"ui.panel.energy.cards.energy_devices_graph_title",
(p) => hasDeviceConsumption(p)
),
entry(
"electricity",
"energy-sankey",
"ui.panel.energy.cards.energy_sankey_title",
(p) => hasDeviceConsumption(p)
),
// --- Gas ---
entry(
"gas",
"energy-gas-graph",
"ui.panel.energy.cards.energy_gas_graph_title",
(p) => hasGasSource(p)
),
entry(
"gas",
"energy-sources-table",
"ui.panel.energy.cards.energy_sources_table_title",
(p) => hasGasSource(p)
),
// --- Water ---
entry(
"water",
"energy-water-graph",
"ui.panel.energy.cards.energy_water_graph_title",
(p) => hasWaterSource(p)
),
entry(
"water",
"energy-sources-table",
"ui.panel.energy.cards.energy_sources_table_title",
(p) => hasWaterSource(p)
),
entry(
"water",
"water-sankey",
"ui.panel.energy.cards.water_sankey_title",
(p) => hasWaterDevices(p)
),
// --- Now (power) ---
entry(
"now",
"power-sources-graph",
"ui.panel.energy.cards.power_sources_graph_title",
(p) => hasPowerSources(p)
),
entry(
"now",
"power-sankey",
"ui.panel.energy.cards.power_sankey_title",
(p) => hasPowerDevices(p)
),
entry(
"now",
"water-flow-sankey",
"ui.panel.energy.cards.water_flow_sankey_title",
(p) => hasPowerWaterDevices(p)
),
];
// --- Lookup helpers --------------------------------------------------------
export const isEnergyCardHidden = (
view: EnergyViewPath,
cardType: string,
hidden: string[] | undefined
): boolean => !!hidden?.includes(energyCardKey(view, cardType));
/** Keys of all catalog cards that apply to the given preferences for a view. */
export const applicableEnergyCardKeys = (
view: EnergyViewPath,
prefs: EnergyPreferences
): string[] =>
ENERGY_CARD_CATALOG.filter(
(c) => c.view === view && c.isApplicable(prefs)
).map((c) => c.key);
/** True when a view has applicable cards but every one of them is hidden. */
export const isEnergyViewEmpty = (
view: EnergyViewPath,
prefs: EnergyPreferences,
hidden: string[] | undefined
): boolean => {
const applicable = applicableEnergyCardKeys(view, prefs);
return (
applicable.length > 0 && applicable.every((key) => hidden?.includes(key))
);
};
@@ -7,13 +7,15 @@ import {
import type { EnergyPreferences } from "../../../data/energy";
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
import type { LovelaceConfig } from "../../../data/lovelace/config/types";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { LovelaceStrategyViewConfig } from "../../../data/lovelace/config/view";
import type { LocalizeKeys } from "../../../common/translations/localize";
import type { HomeAssistant } from "../../../types";
import {
DEFAULT_ENERGY_COLLECTION_KEY,
DEFAULT_POWER_COLLECTION_KEY,
} from "../constants";
import type { EnergyViewPath } from "./energy-cards";
import { isEnergyViewEmpty } from "./energy-cards";
const OVERVIEW_VIEW = {
path: "overview",
@@ -21,7 +23,7 @@ const OVERVIEW_VIEW = {
type: "energy-overview",
collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
},
} as LovelaceViewConfig;
} as LovelaceStrategyViewConfig;
const ENERGY_VIEW = {
path: "electricity",
@@ -29,7 +31,7 @@ const ENERGY_VIEW = {
type: "energy",
collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
},
} as LovelaceViewConfig;
} as LovelaceStrategyViewConfig;
const WATER_VIEW = {
path: "water",
@@ -37,7 +39,7 @@ const WATER_VIEW = {
type: "water",
collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
},
} as LovelaceViewConfig;
} as LovelaceStrategyViewConfig;
const GAS_VIEW = {
path: "gas",
@@ -45,7 +47,7 @@ const GAS_VIEW = {
type: "gas",
collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
},
} as LovelaceViewConfig;
} as LovelaceStrategyViewConfig;
const POWER_VIEW = {
path: "now",
@@ -53,7 +55,7 @@ const POWER_VIEW = {
type: "power",
collection_key: DEFAULT_POWER_COLLECTION_KEY,
},
} as LovelaceViewConfig;
} as LovelaceStrategyViewConfig;
const WIZARD_VIEW = {
type: "panel",
@@ -64,6 +66,7 @@ const WIZARD_VIEW = {
export interface EnergyDashboardStrategyConfig extends LovelaceStrategyConfig {
type: "energy";
default_collection?: string;
hidden_cards?: string[];
}
@customElement("energy-dashboard-strategy")
@@ -112,28 +115,42 @@ export class EnergyDashboardStrategy extends ReactiveElement {
const hasDeviceConsumption = prefs.device_consumption.length > 0;
const views: LovelaceViewConfig[] = [];
const hidden = _config.hidden_cards;
const candidateViews: LovelaceStrategyViewConfig[] = [];
if (hasEnergy || hasDeviceConsumption) {
views.push(ENERGY_VIEW);
candidateViews.push(ENERGY_VIEW);
}
if (hasGas) {
views.push(GAS_VIEW);
candidateViews.push(GAS_VIEW);
}
if (hasWater) {
views.push(WATER_VIEW);
candidateViews.push(WATER_VIEW);
}
if (hasPower) {
views.push(POWER_VIEW);
candidateViews.push(POWER_VIEW);
}
if (
hasPowerSource ||
[hasEnergy, hasGas, hasWater].filter(Boolean).length > 1
) {
views.unshift(OVERVIEW_VIEW);
candidateViews.unshift(OVERVIEW_VIEW);
}
// Drop a view (tab) when every card it would render has been hidden, so we
// don't show an empty tab. Keep at least one view so the dashboard never
// renders blank and the customize entry stays reachable.
let views = candidateViews.filter(
(view) => !isEnergyViewEmpty(view.path as EnergyViewPath, prefs, hidden)
);
if (views.length === 0) {
views = candidateViews;
}
return {
views: views.map((view) => ({
...view,
strategy: { ...view.strategy, hidden_cards: hidden },
title:
view.title ||
hass.localize(`ui.panel.energy.title.${view.path}` as LocalizeKeys),
@@ -4,17 +4,19 @@ 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 { DEFAULT_ENERGY_COLLECTION_KEY } from "../constants";
import type { EnergyViewStrategyConfig } from "./energy-cards";
import { isEnergyCardHidden } from "./energy-cards";
@customElement("energy-overview-view-strategy")
export class EnergyOverviewViewStrategy extends ReactiveElement {
static async generate(
_config: LovelaceStrategyConfig,
_config: EnergyViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const collectionKey =
_config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY;
const hidden = _config.hidden_cards;
const view: LovelaceViewConfig = {
type: "sections",
@@ -73,7 +75,10 @@ export class EnergyOverviewViewStrategy extends ReactiveElement {
return false;
});
if (hasGrid || hasBattery || hasSolar) {
if (
(hasGrid || hasBattery || hasSolar) &&
!isEnergyCardHidden("overview", "energy-distribution", hidden)
) {
view.sections!.push({
type: "grid",
cards: [
@@ -88,7 +93,10 @@ export class EnergyOverviewViewStrategy extends ReactiveElement {
});
}
if (prefs.energy_sources.length) {
if (
prefs.energy_sources.length &&
!isEnergyCardHidden("overview", "energy-sources-table", hidden)
) {
view.sections!.push({
type: "grid",
cards: [
@@ -104,7 +112,10 @@ export class EnergyOverviewViewStrategy extends ReactiveElement {
});
}
if (hasPowerSources) {
if (
hasPowerSources &&
!isEnergyCardHidden("overview", "power-sources-graph", hidden)
) {
view.sections!.push({
type: "grid",
cards: [
@@ -120,7 +131,10 @@ export class EnergyOverviewViewStrategy extends ReactiveElement {
});
}
if (hasGrid || hasBattery) {
if (
(hasGrid || hasBattery) &&
!isEnergyCardHidden("overview", "energy-usage-graph", hidden)
) {
view.sections!.push({
type: "grid",
cards: [
@@ -135,7 +149,7 @@ export class EnergyOverviewViewStrategy extends ReactiveElement {
});
}
if (hasGas) {
if (hasGas && !isEnergyCardHidden("overview", "energy-gas-graph", hidden)) {
view.sections!.push({
type: "grid",
cards: [
@@ -150,7 +164,10 @@ export class EnergyOverviewViewStrategy extends ReactiveElement {
});
}
if (hasWaterSources || hasWaterDevices) {
if (
(hasWaterSources || hasWaterDevices) &&
!isEnergyCardHidden("overview", "energy-water-graph", hidden)
) {
view.sections!.push({
type: "grid",
cards: [
@@ -3,10 +3,11 @@ import { customElement } from "lit/decorators";
import type { GridSourceTypeEnergyPreference } from "../../../data/energy";
import { getEnergyDataCollection } from "../../../data/energy";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../constants";
import type { EnergyViewStrategyConfig } from "./energy-cards";
import { isEnergyCardHidden } from "./energy-cards";
import { shouldShowFloorsAndAreas } from "./show-floors-and-areas";
import {
LARGE_SCREEN_CONDITION,
@@ -16,11 +17,12 @@ import {
@customElement("energy-view-strategy")
export class EnergyViewStrategy extends ReactiveElement {
static async generate(
_config: LovelaceStrategyConfig,
_config: EnergyViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const collectionKey =
_config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY;
const hidden = _config.hidden_cards;
const view: LovelaceViewConfig = {
type: "sections",
@@ -75,7 +77,10 @@ export class EnergyViewStrategy extends ReactiveElement {
const gaugeCards: LovelaceCardConfig[] = [];
const sidebarSection = view.sidebar!.sections![0];
if (hasGrid || hasBattery || hasSolar) {
if (
(hasGrid || hasBattery || hasSolar) &&
!isEnergyCardHidden("electricity", "energy-distribution", hidden)
) {
const distributionCard = {
title: hass.localize("ui.panel.energy.cards.energy_distribution_title"),
type: "energy-distribution",
@@ -91,7 +96,11 @@ export class EnergyViewStrategy extends ReactiveElement {
}
// Only include if we have both grid import and export configured
if (hasGrid && hasReturn) {
if (
hasGrid &&
hasReturn &&
!isEnergyCardHidden("electricity", "energy-grid-balance", hidden)
) {
const gridResultCard = {
type: "energy-grid-balance",
collection_key: collectionKey,
@@ -106,7 +115,10 @@ export class EnergyViewStrategy extends ReactiveElement {
}
// Only include if we have a grid source & return.
if (hasReturn) {
if (
hasReturn &&
!isEnergyCardHidden("electricity", "energy-grid-neutrality-gauge", hidden)
) {
const card = {
type: "energy-grid-neutrality-gauge",
collection_key: collectionKey,
@@ -116,14 +128,28 @@ export class EnergyViewStrategy extends ReactiveElement {
// Only include if we have a solar source.
if (hasSolar) {
if (hasReturn) {
if (
hasReturn &&
!isEnergyCardHidden(
"electricity",
"energy-solar-consumed-gauge",
hidden
)
) {
const card = {
type: "energy-solar-consumed-gauge",
collection_key: collectionKey,
};
gaugeCards.push(card);
}
if (hasGrid) {
if (
hasGrid &&
!isEnergyCardHidden(
"electricity",
"energy-self-sufficiency-gauge",
hidden
)
) {
const card = {
type: "energy-self-sufficiency-gauge",
collection_key: collectionKey,
@@ -133,7 +159,10 @@ export class EnergyViewStrategy extends ReactiveElement {
}
// Only include if we have a grid
if (hasGrid) {
if (
hasGrid &&
!isEnergyCardHidden("electricity", "energy-carbon-consumed-gauge", hidden)
) {
const card = {
type: "energy-carbon-consumed-gauge",
collection_key: collectionKey,
@@ -168,7 +197,10 @@ export class EnergyViewStrategy extends ReactiveElement {
});
// Only include if we have a grid or battery.
if (hasGrid || hasBattery) {
if (
(hasGrid || hasBattery) &&
!isEnergyCardHidden("electricity", "energy-usage-graph", hidden)
) {
mainCards.push({
title: hass.localize("ui.panel.energy.cards.energy_usage_graph_title"),
type: "energy-usage-graph",
@@ -178,7 +210,10 @@ export class EnergyViewStrategy extends ReactiveElement {
}
// Only include if we have a solar source.
if (hasSolar) {
if (
hasSolar &&
!isEnergyCardHidden("electricity", "energy-solar-graph", hidden)
) {
mainCards.push({
title: hass.localize("ui.panel.energy.cards.energy_solar_graph_title"),
type: "energy-solar-graph",
@@ -187,7 +222,10 @@ export class EnergyViewStrategy extends ReactiveElement {
});
}
if (hasGrid || hasSolar || hasBattery) {
if (
(hasGrid || hasSolar || hasBattery) &&
!isEnergyCardHidden("electricity", "energy-sources-table", hidden)
) {
mainCards.push({
title: hass.localize(
"ui.panel.energy.cards.energy_sources_table_title"
@@ -201,35 +239,47 @@ export class EnergyViewStrategy extends ReactiveElement {
// Only include if we have at least 1 device in the config.
if (prefs.device_consumption.length) {
const showFloorsAndAreas = shouldShowFloorsAndAreas(
prefs.device_consumption,
hass,
(d) => d.stat_consumption
);
mainCards.push({
title: hass.localize(
"ui.panel.energy.cards.energy_devices_detail_graph_title"
),
type: "energy-devices-detail-graph",
collection_key: collectionKey,
grid_options: { columns: 36 },
});
mainCards.push({
title: hass.localize(
"ui.panel.energy.cards.energy_devices_graph_title"
),
type: "energy-devices-graph",
collection_key: collectionKey,
grid_options: { columns: 36 },
});
mainCards.push({
title: hass.localize("ui.panel.energy.cards.energy_sankey_title"),
type: "energy-sankey",
collection_key: collectionKey,
group_by_floor: showFloorsAndAreas,
group_by_area: showFloorsAndAreas,
grid_options: { columns: 36 },
});
if (
!isEnergyCardHidden(
"electricity",
"energy-devices-detail-graph",
hidden
)
) {
mainCards.push({
title: hass.localize(
"ui.panel.energy.cards.energy_devices_detail_graph_title"
),
type: "energy-devices-detail-graph",
collection_key: collectionKey,
grid_options: { columns: 36 },
});
}
if (!isEnergyCardHidden("electricity", "energy-devices-graph", hidden)) {
mainCards.push({
title: hass.localize(
"ui.panel.energy.cards.energy_devices_graph_title"
),
type: "energy-devices-graph",
collection_key: collectionKey,
grid_options: { columns: 36 },
});
}
if (!isEnergyCardHidden("electricity", "energy-sankey", hidden)) {
const showFloorsAndAreas = shouldShowFloorsAndAreas(
prefs.device_consumption,
hass,
(d) => d.stat_consumption
);
mainCards.push({
title: hass.localize("ui.panel.energy.cards.energy_sankey_title"),
type: "energy-sankey",
collection_key: collectionKey,
group_by_floor: showFloorsAndAreas,
group_by_area: showFloorsAndAreas,
grid_options: { columns: 36 },
});
}
}
view.sections!.push({
@@ -3,18 +3,20 @@ import { customElement } from "lit/decorators";
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 { DEFAULT_ENERGY_COLLECTION_KEY } from "../constants";
import type { EnergyViewStrategyConfig } from "./energy-cards";
import { isEnergyCardHidden } from "./energy-cards";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
@customElement("gas-view-strategy")
export class GasViewStrategy extends ReactiveElement {
static async generate(
_config: LovelaceStrategyConfig,
_config: EnergyViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const collectionKey =
_config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY;
const hidden = _config.hidden_cards;
const view: LovelaceViewConfig = {
type: "sections",
@@ -57,24 +59,30 @@ export class GasViewStrategy extends ReactiveElement {
},
});
section.cards!.push({
title: hass.localize("ui.panel.energy.cards.energy_gas_graph_title"),
type: "energy-gas-graph",
collection_key: collectionKey,
grid_options: {
columns: 24,
},
});
if (!isEnergyCardHidden("gas", "energy-gas-graph", hidden)) {
section.cards!.push({
title: hass.localize("ui.panel.energy.cards.energy_gas_graph_title"),
type: "energy-gas-graph",
collection_key: collectionKey,
grid_options: {
columns: 24,
},
});
}
section.cards!.push({
title: hass.localize("ui.panel.energy.cards.energy_sources_table_title"),
type: "energy-sources-table",
collection_key: collectionKey,
types: ["gas"],
grid_options: {
columns: 12,
},
});
if (!isEnergyCardHidden("gas", "energy-sources-table", hidden)) {
section.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.energy_sources_table_title"
),
type: "energy-sources-table",
collection_key: collectionKey,
types: ["gas"],
grid_options: {
columns: 12,
},
});
}
return view;
}
@@ -1,10 +1,11 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { getEnergyDataCollection } from "../../../data/energy";
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../constants";
import type { EnergyViewStrategyConfig } from "./energy-cards";
import { isEnergyCardHidden } from "./energy-cards";
import { shouldShowFloorsAndAreas } from "./show-floors-and-areas";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
@@ -12,11 +13,12 @@ import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
@customElement("power-view-strategy")
export class PowerViewStrategy extends ReactiveElement {
static async generate(
_config: LovelaceStrategyConfig,
_config: EnergyViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const collectionKey =
_config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY;
const hidden = _config.hidden_cards;
const energyCollection = getEnergyDataCollection(hass, {
key: collectionKey,
@@ -76,14 +78,18 @@ export class PowerViewStrategy extends ReactiveElement {
collection_key: collectionKey,
});
chartsSection.cards!.push({
title: hass.localize("ui.panel.energy.cards.power_sources_graph_title"),
type: "power-sources-graph",
collection_key: collectionKey,
grid_options: {
columns: 36,
},
});
if (!isEnergyCardHidden("now", "power-sources-graph", hidden)) {
chartsSection.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.power_sources_graph_title"
),
type: "power-sources-graph",
collection_key: collectionKey,
grid_options: {
columns: 36,
},
});
}
}
if (hasGasSources) {
@@ -109,7 +115,7 @@ export class PowerViewStrategy extends ReactiveElement {
}
});
if (hasPowerDevices) {
if (hasPowerDevices && !isEnergyCardHidden("now", "power-sankey", hidden)) {
const showFloorsAndAreas = shouldShowFloorsAndAreas(
prefs.device_consumption,
hass,
@@ -127,7 +133,10 @@ export class PowerViewStrategy extends ReactiveElement {
});
}
if (hasWaterDevices) {
if (
hasWaterDevices &&
!isEnergyCardHidden("now", "water-flow-sankey", hidden)
) {
const showFloorsAndAreas = shouldShowFloorsAndAreas(
prefs.device_consumption_water,
hass,
@@ -2,20 +2,22 @@ import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { getEnergyDataCollection } from "../../../data/energy";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../constants";
import type { EnergyViewStrategyConfig } from "./energy-cards";
import { isEnergyCardHidden } from "./energy-cards";
import { shouldShowFloorsAndAreas } from "./show-floors-and-areas";
@customElement("water-view-strategy")
export class WaterViewStrategy extends ReactiveElement {
static async generate(
_config: LovelaceStrategyConfig,
_config: EnergyViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const collectionKey =
_config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY;
const hidden = _config.hidden_cards;
const view: LovelaceViewConfig = {
type: "sections",
@@ -60,29 +62,38 @@ export class WaterViewStrategy extends ReactiveElement {
});
if (hasWaterSources) {
section.cards!.push({
title: hass.localize("ui.panel.energy.cards.energy_water_graph_title"),
type: "energy-water-graph",
collection_key: collectionKey,
grid_options: {
columns: 24,
},
});
section.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.energy_sources_table_title"
),
type: "energy-sources-table",
collection_key: collectionKey,
types: ["water"],
grid_options: {
columns: 12,
},
});
if (!isEnergyCardHidden("water", "energy-water-graph", hidden)) {
section.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.energy_water_graph_title"
),
type: "energy-water-graph",
collection_key: collectionKey,
grid_options: {
columns: 24,
},
});
}
if (!isEnergyCardHidden("water", "energy-sources-table", hidden)) {
section.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.energy_sources_table_title"
),
type: "energy-sources-table",
collection_key: collectionKey,
types: ["water"],
grid_options: {
columns: 12,
},
});
}
}
// Only include if we have at least 1 water device in the config.
if (hasWaterDevices) {
if (
hasWaterDevices &&
!isEnergyCardHidden("water", "water-sankey", hidden)
) {
const showFloorsAndAreas = shouldShowFloorsAndAreas(
prefs.device_consumption_water,
hass,
@@ -438,7 +438,9 @@ class HuiEnergySankeyCard
}
private _valueFormatter = (value: number) =>
`${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)} kWh`;
`<div style="direction:ltr; display: inline;">
${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)}
kWh</div>`;
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
const { node } = ev.detail;
@@ -580,7 +580,9 @@ class HuiPowerSankeyCard
}
private _valueFormatter = (value: number) =>
formatPowerShort(this.hass, value);
`<div style="direction:ltr; display: inline;">
${formatPowerShort(this.hass, value)}
</div>`;
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
const { node } = ev.detail;
@@ -511,11 +511,9 @@ class HuiWaterFlowSankeyCard
}
private _valueFormatter = (value: number) =>
formatFlowRateShort(
this.hass.locale,
this.hass.config.unit_system.length,
value
);
`<div style="direction:ltr; display: inline;">
${formatFlowRateShort(this.hass.locale, this.hass.config.unit_system.length, value)}
</div>`;
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
const { node } = ev.detail;
@@ -30,7 +30,6 @@ import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip";
import "../../../../components/ha-yaml-editor";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../../resources/styles";
@@ -231,28 +230,11 @@ export class HaCardConditionEditor extends LitElement {
return html`
<div class="container">
<ha-expansion-panel left-chevron>
<div
id="condition-icon"
class="icon-badge-wrapper"
<ha-svg-icon
slot="leading-icon"
>
<ha-svg-icon
.path=${ICON_CONDITION[condition.condition]}
></ha-svg-icon>
${hideLiveTest
? nothing
: html`<ha-automation-row-live-test
.state=${this._liveTestResult.state}
.label=${this.hass.localize(
`ui.panel.lovelace.editor.condition-editor.live_test_state.${this._liveTestResult.state}`
)}
></ha-automation-row-live-test>`}
</div>
${!hideLiveTest && this._liveTestResult.message
? html`<ha-tooltip for="condition-icon" slot="leading-icon"
>${this._liveTestResult.message}</ha-tooltip
>`
: nothing}
class="condition-icon"
.path=${ICON_CONDITION[condition.condition]}
></ha-svg-icon>
<h3 slot="header">
${this.hass.localize(
`ui.panel.lovelace.editor.condition-editor.condition.${condition.condition}.label`
@@ -273,6 +255,18 @@ export class HaCardConditionEditor extends LitElement {
"ui.panel.lovelace.editor.condition-editor.testing_error"
)}
</ha-automation-row-event-chip>
${hideLiveTest
? nothing
: html`
<ha-automation-row-live-test
slot="icons"
.state=${this._liveTestResult.state}
.label=${this.hass.localize(
`ui.panel.lovelace.editor.condition-editor.live_test_state.${this._liveTestResult.state}`
)}
.message=${this._liveTestResult.message}
></ha-automation-row-live-test>
`}
<ha-dropdown
slot="icons"
@wa-select=${this._handleAction}
@@ -485,15 +479,17 @@ export class HaCardConditionEditor extends LitElement {
--expansion-panel-summary-padding: 0 0 0 8px;
--expansion-panel-content-padding: 0;
}
.icon-badge-wrapper {
.condition-icon {
display: none;
}
@media (min-width: 870px) {
.icon-badge-wrapper {
display: inline-flex;
position: relative;
.condition-icon {
display: inline-block;
color: var(--secondary-text-color);
opacity: 0.9;
margin-right: 8px;
margin-inline-end: 8px;
margin-inline-start: initial;
}
}
h3 {
+18 -1
View File
@@ -4138,6 +4138,18 @@
"gas": "Gas",
"water": "Water"
},
"customise": {
"toolbar_action": "Customize cards",
"title": "Customize energy",
"subtitle": "Toggle to show or hide cards",
"saved": "Energy dashboard updated",
"save_failed": "Failed to save energy customization",
"unavailable": "This card isn't shown because the energy source or device it needs isn't configured.",
"groups": {
"overview": "Overview",
"now": "Now"
}
},
"delete_source": "Are you sure you want to remove this source?",
"delete_integration": "Are you sure you want to remove this integration? It will remove the entities it provides",
"grid": {
@@ -11188,7 +11200,12 @@
"energy_top_consumers_title": "Top consumers",
"power_sankey_title": "Current power flow",
"water_flow_sankey_title": "Current water flow",
"power_sources_graph_title": "Power sources"
"power_sources_graph_title": "Power sources",
"energy_grid_balance_title": "Grid energy balance",
"energy_grid_neutrality_gauge_title": "Grid neutrality gauge",
"energy_solar_consumed_gauge_title": "Solar consumed gauge",
"energy_self_sufficiency_gauge_title": "Self-sufficiency gauge",
"energy_carbon_consumed_gauge_title": "Carbon consumed gauge"
}
},
"history": {
@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import type {
EnergyPreferences,
EnergySource,
} from "../../../../src/data/energy";
import {
applicableEnergyCardKeys,
ENERGY_CARD_CATALOG,
energyCardKey,
isEnergyCardHidden,
isEnergyViewEmpty,
} from "../../../../src/panels/energy/strategies/energy-cards";
const source = (s: Partial<EnergySource> & { type: string }): EnergySource =>
s as unknown as EnergySource;
const makePrefs = (
prefs: Partial<EnergyPreferences> = {}
): EnergyPreferences => ({
energy_sources: [],
device_consumption: [],
device_consumption_water: [],
...prefs,
});
const GRID_RETURN = source({
type: "grid",
stat_energy_from: "sensor.grid_in",
stat_energy_to: "sensor.grid_out",
});
const SOLAR = source({ type: "solar", stat_energy_from: "sensor.solar" });
const GAS = source({ type: "gas", stat_energy_from: "sensor.gas" });
const WATER = source({ type: "water", stat_energy_from: "sensor.water" });
describe("energyCardKey", () => {
it("joins the view path and card type", () => {
expect(energyCardKey("electricity", "energy-solar-graph")).toBe(
"electricity.energy-solar-graph"
);
expect(energyCardKey("now", "power-sankey")).toBe("now.power-sankey");
});
});
describe("isEnergyCardHidden", () => {
it("returns true only when the composite key is in the hidden list", () => {
const hidden = ["electricity.energy-solar-graph"];
expect(
isEnergyCardHidden("electricity", "energy-solar-graph", hidden)
).toBe(true);
// Same card type in a different view is independent.
expect(isEnergyCardHidden("overview", "energy-solar-graph", hidden)).toBe(
false
);
expect(
isEnergyCardHidden("electricity", "energy-usage-graph", hidden)
).toBe(false);
});
it("treats undefined/empty hidden lists as nothing hidden", () => {
expect(
isEnergyCardHidden("electricity", "energy-solar-graph", undefined)
).toBe(false);
expect(isEnergyCardHidden("electricity", "energy-solar-graph", [])).toBe(
false
);
});
});
describe("catalog applicability", () => {
it("only lists cards relevant to the configured sources", () => {
const gasOnly = makePrefs({ energy_sources: [GAS] });
expect(applicableEnergyCardKeys("gas", gasOnly)).toEqual([
"gas.energy-gas-graph",
"gas.energy-sources-table",
]);
// No electricity sources -> no electricity cards apply.
expect(applicableEnergyCardKeys("electricity", gasOnly)).toEqual([]);
});
it("gates the solar graph and gauges on their sources", () => {
const solarGraph = ENERGY_CARD_CATALOG.find(
(c) => c.key === "electricity.energy-solar-graph"
)!;
expect(
solarGraph.isApplicable(makePrefs({ energy_sources: [SOLAR] }))
).toBe(true);
expect(
solarGraph.isApplicable(makePrefs({ energy_sources: [GRID_RETURN] }))
).toBe(false);
const neutralityGauge = ENERGY_CARD_CATALOG.find(
(c) => c.key === "electricity.energy-grid-neutrality-gauge"
)!;
// Needs grid export (return).
expect(
neutralityGauge.isApplicable(makePrefs({ energy_sources: [GRID_RETURN] }))
).toBe(true);
expect(
neutralityGauge.isApplicable(makePrefs({ energy_sources: [SOLAR] }))
).toBe(false);
});
});
describe("isEnergyViewEmpty", () => {
const prefs = makePrefs({ energy_sources: [WATER] });
it("is false when no cards in the view are hidden", () => {
expect(isEnergyViewEmpty("water", prefs, undefined)).toBe(false);
});
it("is false when only some applicable cards are hidden", () => {
expect(
isEnergyViewEmpty("water", prefs, ["water.energy-water-graph"])
).toBe(false);
});
it("is true when every applicable card is hidden", () => {
expect(
isEnergyViewEmpty("water", prefs, [
"water.energy-water-graph",
"water.energy-sources-table",
])
).toBe(true);
});
it("is false when the view has no applicable cards at all", () => {
// Water source configured, but the gas view has nothing applicable.
expect(isEnergyViewEmpty("gas", prefs, [])).toBe(false);
});
});
+12 -12
View File
@@ -8557,7 +8557,7 @@ __metadata:
leaflet-draw: "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch"
leaflet.markercluster: "npm:1.5.3"
license-checker-rseidelsohn: "npm:5.0.1"
lint-staged: "npm:17.0.6"
lint-staged: "npm:17.0.5"
lit: "npm:3.3.3"
lit-analyzer: "npm:2.0.3"
lit-html: "npm:3.3.3"
@@ -9936,21 +9936,21 @@ __metadata:
languageName: node
linkType: hard
"lint-staged@npm:17.0.6":
version: 17.0.6
resolution: "lint-staged@npm:17.0.6"
"lint-staged@npm:17.0.5":
version: 17.0.5
resolution: "lint-staged@npm:17.0.5"
dependencies:
listr2: "npm:^10.2.1"
picomatch: "npm:^4.0.4"
string-argv: "npm:^0.3.2"
tinyexec: "npm:1.2.2"
yaml: "npm:^2.9.0"
tinyexec: "npm:^1.1.2"
yaml: "npm:^2.8.4"
dependenciesMeta:
yaml:
optional: true
bin:
lint-staged: bin/lint-staged.js
checksum: 10/371918cfb293ed0ca5d16fc2a1de304b5a95d21b87dc1ea7f3751567c8f8a07971a40349fac8edb5fce3c6ea6713f70922ea90184b142fd432a5bb4db6c316b0
checksum: 10/a0bea43689d68ec0bf6a56943884dbdb96b6b49e2677bf80654d802678b2edf9fc65338ca8ef3fc310f245933ea2a809db1ac94431dc445c57a4d49620d9d4da
languageName: node
linkType: hard
@@ -13172,10 +13172,10 @@ __metadata:
languageName: node
linkType: hard
"tinyexec@npm:1.2.2, tinyexec@npm:^1.0.2":
version: 1.2.2
resolution: "tinyexec@npm:1.2.2"
checksum: 10/e6f3cabafc33a46063868b4e9c0ab76722e21cb46f0177f7bef5a9656e09ea6fa37115d3e47f776aff11aab9ab696b0c840c8e0099fab574b1e37767c4371aec
"tinyexec@npm:^1.0.2, tinyexec@npm:^1.1.2":
version: 1.1.2
resolution: "tinyexec@npm:1.1.2"
checksum: 10/2bbe37f9001c6f5723ab39eb8dc1e88f77e830d7cf2e8f34bb75019eb505fcfe3b061b4799c502ff31fa63aa1a9adc649add5ff1e17b7fbd8c16e1afb75d0b9e
languageName: node
linkType: hard
@@ -14755,7 +14755,7 @@ __metadata:
languageName: node
linkType: hard
"yaml@npm:^2.9.0":
"yaml@npm:^2.8.4":
version: 2.9.0
resolution: "yaml@npm:2.9.0"
bin: