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
76 changed files with 1064 additions and 2734 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

@@ -34,7 +34,7 @@
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.5.11",
"@codemirror/state": "6.5.2",
"@codemirror/view": "6.38.8",
"@codemirror/view": "6.38.6",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.18.2",
@@ -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": "12.0.0",
"glob": "11.0.3",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
@@ -233,10 +233,9 @@
"@fullcalendar/daygrid": "6.1.19",
"globals": "16.5.0",
"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",
"glob@^10.2.2": "^10.5.0"
"@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,53 +0,0 @@
import type { AreaRegistryEntry } from "../../data/area_registry";
import type { FloorRegistryEntry } from "../../data/floor_registry";
export interface AreasFloorHierarchy {
floors: {
id: string;
areas: string[];
}[];
areas: string[];
}
export const getAreasFloorHierarchy = (
floors: FloorRegistryEntry[],
areas: AreaRegistryEntry[]
): AreasFloorHierarchy => {
const floorAreas = new Map<string, string[]>();
const unassignedAreas: string[] = [];
for (const area of areas) {
if (area.floor_id) {
if (!floorAreas.has(area.floor_id)) {
floorAreas.set(area.floor_id, []);
}
floorAreas.get(area.floor_id)!.push(area.area_id);
} else {
unassignedAreas.push(area.area_id);
}
}
const hierarchy: AreasFloorHierarchy = {
floors: floors.map((floor) => ({
id: floor.floor_id,
areas: floorAreas.get(floor.floor_id) || [],
})),
areas: unassignedAreas,
};
return hierarchy;
};
export const getAreasOrder = (hierarchy: AreasFloorHierarchy): string[] => {
const order: string[] = [];
for (const floor of hierarchy.floors) {
order.push(...floor.areas);
}
order.push(...hierarchy.areas);
return order;
};
export const getFloorOrder = (hierarchy: AreasFloorHierarchy): string[] =>
hierarchy.floors.map((floor) => floor.id);

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

@@ -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

@@ -175,10 +175,10 @@ export class HaMdDialog extends Dialog {
}
.container {
margin-top: var(--safe-area-inset-top, var(--ha-space-0));
margin-bottom: var(--safe-area-inset-bottom, var(--ha-space-0));
margin-left: var(--safe-area-inset-left, var(--ha-space-0));
margin-right: var(--safe-area-inset-right, var(--ha-space-0));
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
}
}

View File

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

View File

@@ -33,7 +33,6 @@ import type { HomeAssistant, ValueChangedEvent } from "../types";
import { documentationUrl } from "../util/documentation-url";
import "./ha-checkbox";
import "./ha-icon-button";
import "./ha-markdown";
import "./ha-selector/ha-selector";
import "./ha-service-picker";
import "./ha-service-section-icon";
@@ -685,14 +684,10 @@ export class HaServiceControl extends LitElement {
dataField.key}</span
>
<span slot="description"
><ha-markdown
breaks
allow-svg
.content=${this.hass.localize(
`component.${domain}.services.${serviceName}.fields.${dataField.key}.description`
) || dataField?.description}
></ha-markdown>
</span>
>${this.hass.localize(
`component.${domain}.services.${serviceName}.fields.${dataField.key}.description`
) || dataField?.description}</span
>
<ha-selector
.context=${this._selectorContext(targetEntities)}
.disabled=${this.disabled ||

View File

@@ -33,7 +33,6 @@ import { computeRTL } from "../common/util/compute_rtl";
import { throttle } from "../common/util/throttle";
import { subscribeFrontendUserData } from "../data/frontend";
import type { ActionHandlerDetail } from "../data/lovelace/action_handler";
import { getDefaultPanelUrlPath } from "../data/panel";
import type { PersistentNotification } from "../data/persistent_notification";
import { subscribeNotifications } from "../data/persistent_notification";
import { subscribeRepairsIssueRegistry } from "../data/repairs";
@@ -143,7 +142,7 @@ const defaultPanelSorter = (
export const computePanels = memoizeOne(
(
panels: HomeAssistant["panels"],
defaultPanel: string,
defaultPanel: HomeAssistant["defaultPanel"],
panelsOrder: string[],
hiddenPanels: string[],
locale: HomeAssistant["locale"]
@@ -299,8 +298,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
hass.localize !== oldHass.localize ||
hass.locale !== oldHass.locale ||
hass.states !== oldHass.states ||
hass.userData !== oldHass.userData ||
hass.systemData !== oldHass.systemData ||
hass.defaultPanel !== oldHass.defaultPanel ||
hass.connected !== oldHass.connected
);
}
@@ -403,11 +401,9 @@ class HaSidebar extends SubscribeMixin(LitElement) {
`;
}
const defaultPanel = getDefaultPanelUrlPath(this.hass);
const [beforeSpacer, afterSpacer] = computePanels(
this.hass.panels,
defaultPanel,
this.hass.defaultPanel,
this._panelOrder,
this._hiddenPanels,
this.hass.locale
@@ -422,27 +418,23 @@ class HaSidebar extends SubscribeMixin(LitElement) {
@scroll=${this._listboxScroll}
@keydown=${this._listboxKeydown}
>
${this._renderPanels(beforeSpacer, selectedPanel, defaultPanel)}
${this._renderPanels(beforeSpacer, selectedPanel)}
${this._renderSpacer()}
${this._renderPanels(afterSpacer, selectedPanel, defaultPanel)}
${this._renderPanels(afterSpacer, selectedPanel)}
${this._renderExternalConfiguration()}
</ha-md-list>
`;
}
private _renderPanels(
panels: PanelInfo[],
selectedPanel: string,
defaultPanel: string
) {
private _renderPanels(panels: PanelInfo[], selectedPanel: string) {
return panels.map((panel) =>
this._renderPanel(
panel.url_path,
panel.url_path === defaultPanel
panel.url_path === this.hass.defaultPanel
? panel.title || this.hass.localize("panel.states")
: this.hass.localize(`panel.${panel.title}`) || panel.title,
panel.icon,
panel.url_path === defaultPanel && !panel.icon
panel.url_path === this.hass.defaultPanel && !panel.icon
? PANEL_ICONS.lovelace
: panel.url_path in PANEL_ICONS
? PANEL_ICONS[panel.url_path]

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

@@ -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

@@ -1,4 +1,3 @@
import { getAreasFloorHierarchy } from "../common/areas/areas-floor-hierarchy";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeDomain } from "../common/entity/compute_domain";
import { computeFloorName } from "../common/entity/compute_floor_name";
@@ -13,7 +12,11 @@ import {
} from "./device_registry";
import type { HaEntityPickerEntityFilterFunc } from "./entity";
import type { EntityRegistryDisplayEntry } from "./entity_registry";
import type { FloorRegistryEntry } from "./floor_registry";
import {
floorCompare,
getFloorAreaLookup,
type FloorRegistryEntry,
} from "./floor_registry";
export interface FloorComboBoxItem extends PickerComboBoxItem {
type: "floor" | "area";
@@ -179,59 +182,68 @@ export const getAreasAndFloors = (
);
}
const hierarchy = getAreasFloorHierarchy(floors, outputAreas);
const floorAreaLookup = getFloorAreaLookup(outputAreas);
const unassignedAreas = Object.values(outputAreas).filter(
(area) => !area.floor_id || !floorAreaLookup[area.floor_id]
);
const compare = floorCompare(haFloors);
// @ts-ignore
const floorAreaEntries: [
FloorRegistryEntry | undefined,
AreaRegistryEntry[],
][] = Object.entries(floorAreaLookup)
.map(([floorId, floorAreas]) => {
const floor = floors.find((fl) => fl.floor_id === floorId)!;
return [floor, floorAreas] as const;
})
.sort(([floorA], [floorB]) => compare(floorA.floor_id, floorB.floor_id));
const items: FloorComboBoxItem[] = [];
hierarchy.floors.forEach((f) => {
const floor = haFloors[f.id];
const floorAreas = f.areas.map((areaId) => haAreas[areaId]);
floorAreaEntries.forEach(([floor, floorAreas]) => {
if (floor) {
const floorName = computeFloorName(floor);
const floorName = computeFloorName(floor);
const areaSearchLabels = floorAreas
.map((area) => {
const areaName = computeAreaName(area);
return [area.area_id, ...(areaName ? [areaName] : []), ...area.aliases];
})
.flat();
items.push({
id: formatId({ id: floor.floor_id, type: "floor" }),
type: "floor",
primary: floorName,
floor: floor,
icon: floor.icon || undefined,
search_labels: [
floor.floor_id,
floorName,
...floor.aliases,
...areaSearchLabels,
],
});
const areaSearchLabels = floorAreas
.map((area) => {
const areaName = computeAreaName(area) || area.area_id;
return [area.area_id, areaName, ...area.aliases];
})
.flat();
items.push({
id: formatId({ id: floor.floor_id, type: "floor" }),
type: "floor",
primary: floorName,
floor: floor,
icon: floor.icon || undefined,
search_labels: [
floor.floor_id,
floorName,
...floor.aliases,
...areaSearchLabels,
],
});
}
items.push(
...floorAreas.map((area) => {
const areaName = computeAreaName(area);
const areaName = computeAreaName(area) || area.area_id;
return {
id: formatId({ id: area.area_id, type: "area" }),
type: "area" as const,
primary: areaName || area.area_id,
primary: areaName,
area: area,
icon: area.icon || undefined,
search_labels: [
area.area_id,
...(areaName ? [areaName] : []),
...area.aliases,
],
search_labels: [area.area_id, areaName, ...area.aliases],
};
})
);
});
items.push(
...hierarchy.areas.map((areaId) => {
const area = haAreas[areaId];
...unassignedAreas.map((area) => {
const areaName = computeAreaName(area) || area.area_id;
return {
id: formatId({ id: area.area_id, type: "area" }),

View File

@@ -59,15 +59,6 @@ export const deleteAreaRegistryEntry = (hass: HomeAssistant, areaId: string) =>
area_id: areaId,
});
export const reorderAreaRegistryEntries = (
hass: HomeAssistant,
areaIds: string[]
) =>
hass.callWS({
type: "config/area_registry/reorder",
area_ids: areaIds,
});
export const getAreaEntityLookup = (
entities: EntityRegistryEntry[]
): AreaEntityLookup => {

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

@@ -51,15 +51,6 @@ export const deleteFloorRegistryEntry = (
floor_id: floorId,
});
export const reorderFloorRegistryEntries = (
hass: HomeAssistant,
floorIds: string[]
) =>
hass.callWS({
type: "config/floor_registry/reorder",
floor_ids: floorIds,
});
export const getFloorAreaLookup = (
areas: AreaRegistryEntry[]
): FloorAreaLookup => {

View File

@@ -3,7 +3,6 @@ import type { Connection } from "home-assistant-js-websocket";
export interface CoreFrontendUserData {
showAdvanced?: boolean;
showEntityIdPicker?: boolean;
defaultPanel?: string;
}
export interface SidebarFrontendUserData {
@@ -11,24 +10,15 @@ export interface SidebarFrontendUserData {
hiddenPanels: string[];
}
export interface CoreFrontendSystemData {
defaultPanel?: string;
}
declare global {
interface FrontendUserData {
core: CoreFrontendUserData;
sidebar: SidebarFrontendUserData;
}
interface FrontendSystemData {
core: CoreFrontendSystemData;
}
}
export type ValidUserDataKey = keyof FrontendUserData;
export type ValidSystemDataKey = keyof FrontendSystemData;
export const fetchFrontendUserData = async <
UserDataKey extends ValidUserDataKey,
>(
@@ -69,46 +59,3 @@ export const subscribeFrontendUserData = <UserDataKey extends ValidUserDataKey>(
key: userDataKey,
}
);
export const fetchFrontendSystemData = async <
SystemDataKey extends ValidSystemDataKey,
>(
conn: Connection,
key: SystemDataKey
): Promise<FrontendSystemData[SystemDataKey] | null> => {
const result = await conn.sendMessagePromise<{
value: FrontendSystemData[SystemDataKey] | null;
}>({
type: "frontend/get_system_data",
key,
});
return result.value;
};
export const saveFrontendSystemData = async <
SystemDataKey extends ValidSystemDataKey,
>(
conn: Connection,
key: SystemDataKey,
value: FrontendSystemData[SystemDataKey]
): Promise<void> =>
conn.sendMessagePromise<undefined>({
type: "frontend/set_system_data",
key,
value,
});
export const subscribeFrontendSystemData = <
SystemDataKey extends ValidSystemDataKey,
>(
conn: Connection,
systemDataKey: SystemDataKey,
onChange: (data: { value: FrontendSystemData[SystemDataKey] | null }) => void
) =>
conn.subscribeMessage<{ value: FrontendSystemData[SystemDataKey] | null }>(
onChange,
{
type: "frontend/subscribe_system_data",
key: systemDataKey,
}
);

View File

@@ -1,25 +1,27 @@
import { fireEvent } from "../common/dom/fire_event";
import type { HomeAssistant, PanelInfo } from "../types";
/** Panel to show when no panel is picked. */
export const DEFAULT_PANEL = "lovelace";
export const getLegacyDefaultPanelUrlPath = (): string | null => {
export const getStorageDefaultPanelUrlPath = (): string => {
const defaultPanel = window.localStorage.getItem("defaultPanel");
return defaultPanel ? JSON.parse(defaultPanel) : null;
return defaultPanel ? JSON.parse(defaultPanel) : DEFAULT_PANEL;
};
export const getDefaultPanelUrlPath = (hass: HomeAssistant): string =>
hass.userData?.defaultPanel ||
hass.systemData?.defaultPanel ||
getLegacyDefaultPanelUrlPath() ||
DEFAULT_PANEL;
export const getDefaultPanel = (hass: HomeAssistant): PanelInfo => {
const panel = getDefaultPanelUrlPath(hass);
return (panel ? hass.panels[panel] : undefined) ?? hass.panels[DEFAULT_PANEL];
export const setDefaultPanel = (
element: HTMLElement,
urlPath: string
): void => {
fireEvent(element, "hass-default-panel", { defaultPanel: urlPath });
};
export const getDefaultPanel = (hass: HomeAssistant): PanelInfo =>
hass.panels[hass.defaultPanel]
? hass.panels[hass.defaultPanel]
: hass.panels[DEFAULT_PANEL];
export const getPanelNameTranslationKey = (panel: PanelInfo) => {
if (panel.url_path === "lovelace") {
return "panel.states" as const;

View File

@@ -20,7 +20,6 @@ import {
} from "../../data/frontend";
import type { HomeAssistant } from "../../types";
import { showConfirmationDialog } from "../generic/show-dialog-box";
import { getDefaultPanelUrlPath } from "../../data/panel";
@customElement("dialog-edit-sidebar")
class DialogEditSidebar extends LitElement {
@@ -95,11 +94,9 @@ class DialogEditSidebar extends LitElement {
const panels = this._panels(this.hass.panels);
const defaultPanel = getDefaultPanelUrlPath(this.hass);
const [beforeSpacer, afterSpacer] = computePanels(
this.hass.panels,
defaultPanel,
this.hass.defaultPanel,
this._order,
this._hidden,
this.hass.locale
@@ -123,12 +120,12 @@ class DialogEditSidebar extends LitElement {
].map((panel) => ({
value: panel.url_path,
label:
panel.url_path === defaultPanel
panel.url_path === this.hass.defaultPanel
? panel.title || this.hass.localize("panel.states")
: this.hass.localize(`panel.${panel.title}`) || panel.title || "?",
icon: panel.icon || undefined,
iconPath:
panel.url_path === defaultPanel && !panel.icon
panel.url_path === this.hass.defaultPanel && !panel.icon
? PANEL_ICONS.lovelace
: panel.url_path in PANEL_ICONS
? PANEL_ICONS[panel.url_path]

View File

@@ -1,5 +1,5 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
@@ -46,13 +46,10 @@ export class HomeAssistantMain extends LitElement {
protected render(): TemplateResult {
const sidebarNarrow = this._sidebarNarrow || this._externalSidebar;
const isPanelReady =
this.hass.panels && this.hass.userData && this.hass.systemData;
return html`
<ha-drawer
.type=${sidebarNarrow ? "modal" : ""}
.open=${sidebarNarrow ? this._drawerOpen : false}
.open=${sidebarNarrow ? this._drawerOpen : undefined}
.direction=${computeRTLDirection(this.hass)}
@MDCDrawer:closed=${this._drawerClosed}
>
@@ -62,14 +59,12 @@ export class HomeAssistantMain extends LitElement {
.route=${this.route}
.alwaysExpand=${sidebarNarrow || this.hass.dockedSidebar === "docked"}
></ha-sidebar>
${isPanelReady
? html`<partial-panel-resolver
.narrow=${this.narrow}
.hass=${this.hass}
.route=${this.route}
slot="appContent"
></partial-panel-resolver>`
: nothing}
<partial-panel-resolver
.narrow=${this.narrow}
.hass=${this.hass}
.route=${this.route}
slot="appContent"
></partial-panel-resolver>
</ha-drawer>
`;
}

View File

@@ -1,10 +1,10 @@
import type { Connection } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html } from "lit";
import { customElement, state } from "lit/decorators";
import { storage } from "../common/decorators/storage";
import type { Connection } from "home-assistant-js-websocket";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { navigate } from "../common/navigate";
import { getStorageDefaultPanelUrlPath } from "../data/panel";
import type { WindowWithPreloads } from "../data/preloads";
import type { RecorderInfo } from "../data/recorder";
import { getRecorderInfo } from "../data/recorder";
@@ -23,6 +23,7 @@ import {
} from "../util/register-service-worker";
import "./ha-init-page";
import "./home-assistant-main";
import { storage } from "../common/decorators/storage";
const useHash = __DEMO__;
const curPath = () =>
@@ -52,6 +53,11 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
super();
const path = curPath();
if (["", "/"].includes(path)) {
navigate(`/${getStorageDefaultPanelUrlPath()}${location.search}`, {
replace: true,
});
}
this._route = {
prefix: "",
path,

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

@@ -1,17 +1,21 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
import {
findEntities,
generateEntityFilter,
type EntityFilter,
} from "../../../common/entity/entity_filter";
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import {
computeAreaTileCardConfig,
getAreas,
getFloors,
} from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import { getHomeStructure } from "../../lovelace/strategies/home/helpers/home-structure";
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
export interface ClimateViewStrategyConfig {
type: "climate";
@@ -135,9 +139,9 @@ export class ClimateViewStrategy extends ReactiveElement {
_config: ClimateViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const areas = Object.values(hass.areas);
const floors = Object.values(hass.floors);
const hierarchy = getAreasFloorHierarchy(floors, areas);
const areas = getAreas(hass.areas);
const floors = getFloors(hass.floors);
const home = getHomeStructure(floors, areas);
const sections: LovelaceSectionRawConfig[] = [];
@@ -149,11 +153,10 @@ export class ClimateViewStrategy extends ReactiveElement {
const entities = findEntities(allEntities, climateFilters);
const floorCount =
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
const floorCount = home.floors.length + (home.areas.length ? 1 : 0);
// Process floors
for (const floorStructure of hierarchy.floors) {
for (const floorStructure of home.floors) {
const floorId = floorStructure.id;
const areaIds = floorStructure.areas;
const floor = hass.floors[floorId];
@@ -182,7 +185,7 @@ export class ClimateViewStrategy extends ReactiveElement {
}
// Process unassigned areas
if (hierarchy.areas.length > 0) {
if (home.areas.length > 0) {
const section: LovelaceSectionRawConfig = {
type: "grid",
column_span: 2,
@@ -197,7 +200,7 @@ export class ClimateViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForClimate(hierarchy.areas, hass, entities);
const areaCards = processAreasForClimate(home.areas, hass, entities);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);

View File

@@ -2,47 +2,38 @@ import type { ActionDetail } from "@material/mwc-list";
import {
mdiDelete,
mdiDotsVertical,
mdiDragHorizontalVariant,
mdiHelpCircle,
mdiPencil,
mdiPlus,
} from "@mdi/js";
import {
css,
html,
LitElement,
nothing,
type PropertyValues,
type TemplateResult,
css,
html,
nothing,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import {
getAreasFloorHierarchy,
getAreasOrder,
getFloorOrder,
type AreasFloorHierarchy,
} from "../../../common/areas/areas-floor-hierarchy";
import { formatListWithAnds } from "../../../common/string/format-list";
import "../../../components/ha-fab";
import "../../../components/ha-floor-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-list-item";
import "../../../components/ha-sortable";
import type { HaSortableOptions } from "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import type { AreaRegistryEntry } from "../../../data/area_registry";
import {
createAreaRegistryEntry,
reorderAreaRegistryEntries,
updateAreaRegistryEntry,
} from "../../../data/area_registry";
import type { FloorRegistryEntry } from "../../../data/floor_registry";
import {
createFloorRegistryEntry,
deleteFloorRegistryEntry,
reorderFloorRegistryEntries,
getFloorAreaLookup,
updateFloorRegistryEntry,
} from "../../../data/floor_registry";
import {
@@ -51,7 +42,6 @@ import {
} from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage";
import type { HomeAssistant, Route } from "../../../types";
import { showToast } from "../../../util/toast";
import "../ha-config-section";
import { configSections } from "../ha-panel-config";
import {
@@ -62,17 +52,7 @@ import { showFloorRegistryDetailDialog } from "./show-dialog-floor-registry-deta
const UNASSIGNED_FLOOR = "__unassigned__";
const SORT_OPTIONS: HaSortableOptions = {
sort: true,
delay: 500,
delayOnTouchOnly: true,
};
interface AreaStats {
devices: number;
services: number;
entities: number;
}
const SORT_OPTIONS = { sort: false, delay: 500, delayOnTouchOnly: true };
@customElement("ha-config-areas-dashboard")
export class HaConfigAreasDashboard extends LitElement {
@@ -84,50 +64,55 @@ export class HaConfigAreasDashboard extends LitElement {
@property({ attribute: false }) public route!: Route;
@state() private _hierarchy?: AreasFloorHierarchy;
@state() private _areas: AreaRegistryEntry[] = [];
private _blockHierarchyUpdate = false;
private _blockHierarchyUpdateTimeout?: number;
private _processAreasStats = memoizeOne(
private _processAreas = memoizeOne(
(
areas: HomeAssistant["areas"],
areas: AreaRegistryEntry[],
devices: HomeAssistant["devices"],
entities: HomeAssistant["entities"]
): Map<string, AreaStats> => {
const computeAreaStats = (area: AreaRegistryEntry) => {
let devicesCount = 0;
let servicesCount = 0;
let entitiesCount = 0;
entities: HomeAssistant["entities"],
floors: HomeAssistant["floors"]
) => {
const processArea = (area: AreaRegistryEntry) => {
let noDevicesInArea = 0;
let noServicesInArea = 0;
let noEntitiesInArea = 0;
for (const device of Object.values(devices)) {
if (device.area_id === area.area_id) {
if (device.entry_type === "service") {
servicesCount++;
noServicesInArea++;
} else {
devicesCount++;
noDevicesInArea++;
}
}
}
for (const entity of Object.values(entities)) {
if (entity.area_id === area.area_id) {
entitiesCount++;
noEntitiesInArea++;
}
}
return {
devices: devicesCount,
services: servicesCount,
entities: entitiesCount,
...area,
devices: noDevicesInArea,
services: noServicesInArea,
entities: noEntitiesInArea,
};
};
const areaStats = new Map<string, AreaStats>();
Object.values(areas).forEach((area) => {
areaStats.set(area.area_id, computeAreaStats(area));
});
return areaStats;
const floorAreaLookup = getFloorAreaLookup(areas);
const unassignedAreas = areas.filter(
(area) => !area.floor_id || !floorAreaLookup[area.floor_id]
);
return {
floors: Object.values(floors).map((floor) => ({
...floor,
areas: (floorAreaLookup[floor.floor_id] || []).map(processArea),
})),
unassignedAreas: unassignedAreas.map(processArea),
};
}
);
@@ -135,32 +120,25 @@ export class HaConfigAreasDashboard extends LitElement {
super.willUpdate(changedProperties);
if (changedProperties.has("hass")) {
const oldHass = changedProperties.get("hass");
if (
(this.hass.areas !== oldHass?.areas ||
this.hass.floors !== oldHass?.floors) &&
!this._blockHierarchyUpdate
) {
this._computeHierarchy();
if (this.hass.areas !== oldHass?.areas) {
this._areas = Object.values(this.hass.areas);
}
}
}
private _computeHierarchy() {
this._hierarchy = getAreasFloorHierarchy(
Object.values(this.hass.floors),
Object.values(this.hass.areas)
);
}
protected render(): TemplateResult<1> | typeof nothing {
if (!this._hierarchy) {
return nothing;
}
const areasStats = this._processAreasStats(
this.hass.areas,
this.hass.devices,
this.hass.entities
);
protected render(): TemplateResult {
const areasAndFloors =
!this.hass.areas ||
!this.hass.devices ||
!this.hass.entities ||
!this.hass.floors
? undefined
: this._processAreas(
this._areas,
this.hass.devices,
this.hass.entities,
this.hass.floors
);
return html`
<hass-tabs-subpage
@@ -179,120 +157,81 @@ export class HaConfigAreasDashboard extends LitElement {
@click=${this._showHelp}
></ha-icon-button>
<div class="container">
<ha-sortable
handle-selector=".handle"
draggable-selector=".floor"
@item-moved=${this._floorMoved}
.options=${SORT_OPTIONS}
group="floors"
invert-swap
>
<div class="floors">
${this._hierarchy.floors.map(({ areas, id }) => {
const floor = this.hass.floors[id];
if (!floor) {
return nothing;
}
return html`
<div class="floor">
<div class="header">
<h2>
<ha-floor-icon .floor=${floor}></ha-floor-icon>
${floor.name}
</h2>
<div class="actions">
<ha-svg-icon
class="handle"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
<ha-button-menu
.floor=${floor}
@action=${this._handleFloorAction}
>
<ha-icon-button
slot="trigger"
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item graphic="icon"
><ha-svg-icon
.path=${mdiPencil}
slot="graphic"
></ha-svg-icon
>${this.hass.localize(
"ui.panel.config.areas.picker.floor.edit_floor"
)}</ha-list-item
>
<ha-list-item class="warning" graphic="icon"
><ha-svg-icon
class="warning"
.path=${mdiDelete}
slot="graphic"
></ha-svg-icon
>${this.hass.localize(
"ui.panel.config.areas.picker.floor.delete_floor"
)}</ha-list-item
>
</ha-button-menu>
</div>
</div>
<ha-sortable
handle-selector="a"
draggable-selector="a"
@item-added=${this._areaAdded}
@item-moved=${this._areaMoved}
group="areas"
.options=${SORT_OPTIONS}
.floor=${floor.floor_id}
>
<div class="areas">
${areas.map((areaId) => {
const area = this.hass.areas[areaId];
if (!area) {
return nothing;
}
const stats = areasStats.get(area.area_id);
return this._renderArea(area, stats);
})}
</div>
</ha-sortable>
</div>
`;
})}
</div>
</ha-sortable>
${this._hierarchy.areas.length
? html`
<div class="floor">
<div class="header">
<h2>
${this.hass.localize(
"ui.panel.config.areas.picker.unassigned_areas"
)}
</h2>
</div>
<ha-sortable
handle-selector="a"
draggable-selector="a"
@item-added=${this._areaAdded}
@item-moved=${this._areaMoved}
group="areas"
.options=${SORT_OPTIONS}
.floor=${UNASSIGNED_FLOOR}
${areasAndFloors?.floors.map(
(floor) =>
html`<div class="floor">
<div class="header">
<h2>
<ha-floor-icon .floor=${floor}></ha-floor-icon>
${floor.name}
</h2>
<ha-button-menu
.floor=${floor}
@action=${this._handleFloorAction}
>
<div class="areas">
${this._hierarchy.areas.map((areaId) => {
const area = this.hass.areas[areaId];
if (!area) {
return nothing;
}
const stats = areasStats.get(area.area_id);
return this._renderArea(area, stats);
})}
</div>
</ha-sortable>
<ha-icon-button
slot="trigger"
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item graphic="icon"
><ha-svg-icon
.path=${mdiPencil}
slot="graphic"
></ha-svg-icon
>${this.hass.localize(
"ui.panel.config.areas.picker.floor.edit_floor"
)}</ha-list-item
>
<ha-list-item class="warning" graphic="icon"
><ha-svg-icon
class="warning"
.path=${mdiDelete}
slot="graphic"
></ha-svg-icon
>${this.hass.localize(
"ui.panel.config.areas.picker.floor.delete_floor"
)}</ha-list-item
>
</ha-button-menu>
</div>
`
<ha-sortable
handle-selector="a"
draggable-selector="a"
@item-added=${this._areaAdded}
group="floor"
.options=${SORT_OPTIONS}
.floor=${floor.floor_id}
>
<div class="areas">
${floor.areas.map((area) => this._renderArea(area))}
</div>
</ha-sortable>
</div>`
)}
${areasAndFloors?.unassignedAreas.length
? html`<div class="floor">
<div class="header">
<h2>
${this.hass.localize(
"ui.panel.config.areas.picker.unassigned_areas"
)}
</h2>
</div>
<ha-sortable
handle-selector="a"
draggable-selector="a"
@item-added=${this._areaAdded}
group="floor"
.options=${SORT_OPTIONS}
.floor=${UNASSIGNED_FLOOR}
>
<div class="areas">
${areasAndFloors?.unassignedAreas.map((area) =>
this._renderArea(area)
)}
</div>
</ha-sortable>
</div>`
: nothing}
</div>
<ha-fab
@@ -320,60 +259,56 @@ export class HaConfigAreasDashboard extends LitElement {
`;
}
private _renderArea(
area: AreaRegistryEntry,
stats: AreaStats | undefined
): TemplateResult<1> {
return html`
<a href=${`/config/areas/area/${area.area_id}`} .sortableData=${area}>
<ha-card outlined>
<div
style=${styleMap({
backgroundImage: area.picture
? `url(${area.picture})`
: undefined,
})}
class="picture ${!area.picture ? "placeholder" : ""}"
>
${!area.picture && area.icon
? html`<ha-icon .icon=${area.icon}></ha-icon>`
: ""}
private _renderArea(area) {
return html`<a
href=${`/config/areas/area/${area.area_id}`}
.sortableData=${area}
>
<ha-card outlined>
<div
style=${styleMap({
backgroundImage: area.picture ? `url(${area.picture})` : undefined,
})}
class="picture ${!area.picture ? "placeholder" : ""}"
>
${!area.picture && area.icon
? html`<ha-icon .icon=${area.icon}></ha-icon>`
: ""}
</div>
<div class="card-header">
${area.name}
<ha-icon-button
.area=${area}
.path=${mdiPencil}
@click=${this._openAreaDetails}
></ha-icon-button>
</div>
<div class="card-content">
<div>
${formatListWithAnds(
this.hass.locale,
[
area.devices &&
this.hass.localize(
"ui.panel.config.integrations.config_entry.devices",
{ count: area.devices }
),
area.services &&
this.hass.localize(
"ui.panel.config.integrations.config_entry.services",
{ count: area.services }
),
area.entities &&
this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: area.entities }
),
].filter((v): v is string => Boolean(v))
)}
</div>
<div class="card-header">
${area.name}
<ha-icon-button
.area=${area}
.path=${mdiPencil}
@click=${this._openAreaDetails}
></ha-icon-button>
</div>
<div class="card-content">
<div>
${formatListWithAnds(
this.hass.locale,
[
stats?.devices &&
this.hass.localize(
"ui.panel.config.integrations.config_entry.devices",
{ count: stats.devices }
),
stats?.services &&
this.hass.localize(
"ui.panel.config.integrations.config_entry.services",
{ count: stats.services }
),
stats?.entities &&
this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: stats.entities }
),
].filter((v): v is string => Boolean(v))
)}
</div>
</div>
</ha-card>
</a>
`;
</div>
</ha-card>
</a>`;
}
protected firstUpdated(changedProps) {
@@ -391,170 +326,24 @@ export class HaConfigAreasDashboard extends LitElement {
});
}
private async _floorMoved(ev) {
ev.stopPropagation();
if (!this.hass || !this._hierarchy) {
return;
}
const { oldIndex, newIndex } = ev.detail;
const reorderFloors = (
floors: AreasFloorHierarchy["floors"],
oldIdx: number,
newIdx: number
) => {
const newFloors = [...floors];
const [movedFloor] = newFloors.splice(oldIdx, 1);
newFloors.splice(newIdx, 0, movedFloor);
return newFloors;
};
// Optimistically update UI
this._hierarchy = {
...this._hierarchy,
floors: reorderFloors(this._hierarchy.floors, oldIndex, newIndex),
};
const areaOrder = getAreasOrder(this._hierarchy);
const floorOrder = getFloorOrder(this._hierarchy);
// Block hierarchy updates for 500ms to avoid flickering
// because of multiple async updates
this._blockHierarchyUpdateFor(500);
try {
await reorderAreaRegistryEntries(this.hass, areaOrder);
await reorderFloorRegistryEntries(this.hass, floorOrder);
} catch {
showToast(this, {
message: this.hass.localize(
"ui.panel.config.areas.picker.floor_reorder_failed"
),
});
// Revert on error
this._computeHierarchy();
}
}
private async _areaMoved(ev) {
ev.stopPropagation();
if (!this.hass || !this._hierarchy) {
return;
}
const { floor } = ev.currentTarget;
const { oldIndex, newIndex } = ev.detail;
const floorId = floor === UNASSIGNED_FLOOR ? null : floor;
// Reorder areas within the same floor
const reorderAreas = (areas: string[], oldIdx: number, newIdx: number) => {
const newAreas = [...areas];
const [movedArea] = newAreas.splice(oldIdx, 1);
newAreas.splice(newIdx, 0, movedArea);
return newAreas;
};
// Optimistically update UI
this._hierarchy = {
...this._hierarchy,
floors: this._hierarchy.floors.map((f) => {
if (f.id === floorId) {
return {
...f,
areas: reorderAreas(f.areas, oldIndex, newIndex),
};
}
return f;
}),
areas:
floorId === null
? reorderAreas(this._hierarchy.areas, oldIndex, newIndex)
: this._hierarchy.areas,
};
const areaOrder = getAreasOrder(this._hierarchy);
try {
await reorderAreaRegistryEntries(this.hass, areaOrder);
} catch {
showToast(this, {
message: this.hass.localize(
"ui.panel.config.areas.picker.area_move_failed"
),
});
// Revert on error
this._computeHierarchy();
}
}
private async _areaAdded(ev) {
ev.stopPropagation();
if (!this.hass || !this._hierarchy) {
return;
}
const { floor } = ev.currentTarget;
const { data: area, index } = ev.detail;
const newFloorId = floor === UNASSIGNED_FLOOR ? null : floor;
// Insert area at the specified index
const insertAtIndex = (areas: string[], areaId: string, idx: number) => {
const newAreas = [...areas];
newAreas.splice(idx, 0, areaId);
return newAreas;
};
const { data: area } = ev.detail;
// Optimistically update UI
this._hierarchy = {
...this._hierarchy,
floors: this._hierarchy.floors.map((f) => {
if (f.id === newFloorId) {
return {
...f,
areas: insertAtIndex(f.areas, area.area_id, index),
};
}
return {
...f,
areas: f.areas.filter((id) => id !== area.area_id),
};
}),
areas:
newFloorId === null
? insertAtIndex(this._hierarchy.areas, area.area_id, index)
: this._hierarchy.areas.filter((id) => id !== area.area_id),
};
this._areas = this._areas.map<AreaRegistryEntry>((a) => {
if (a.area_id === area.area_id) {
return { ...a, floor_id: newFloorId };
}
return a;
});
const areaOrder = getAreasOrder(this._hierarchy);
// Block hierarchy updates for 500ms to avoid flickering
// because of multiple async updates
this._blockHierarchyUpdateFor(500);
try {
await reorderAreaRegistryEntries(this.hass, areaOrder);
await updateAreaRegistryEntry(this.hass, area.area_id, {
floor_id: newFloorId,
});
} catch {
showToast(this, {
message: this.hass.localize(
"ui.panel.config.areas.picker.area_move_failed"
),
});
// Revert on error
this._computeHierarchy();
}
}
private _blockHierarchyUpdateFor(time: number) {
this._blockHierarchyUpdate = true;
if (this._blockHierarchyUpdateTimeout) {
window.clearTimeout(this._blockHierarchyUpdateTimeout);
}
this._blockHierarchyUpdateTimeout = window.setTimeout(() => {
this._blockHierarchyUpdate = false;
}, time);
await updateAreaRegistryEntry(this.hass, area.area_id, {
floor_id: newFloorId,
});
}
private _handleFloorAction(ev: CustomEvent<ActionDetail>) {
@@ -674,10 +463,6 @@ export class HaConfigAreasDashboard extends LitElement {
.header ha-icon {
margin-inline-end: 8px;
}
.header .actions {
display: flex;
align-items: center;
}
.areas {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
@@ -688,10 +473,6 @@ export class HaConfigAreasDashboard extends LitElement {
.areas > * {
max-width: 500px;
}
.handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
}
ha-card {
overflow: hidden;
}

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

@@ -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

@@ -4,20 +4,18 @@ import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import { slugify } from "../../../../common/string/slugify";
import "../../../../components/ha-button";
import { createCloseHeading } from "../../../../components/ha-dialog";
import "../../../../components/ha-form/ha-form";
import "../../../../components/ha-button";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import { saveFrontendSystemData } from "../../../../data/frontend";
import type {
LovelaceDashboard,
LovelaceDashboardCreateParams,
LovelaceDashboardMutableParams,
} from "../../../../data/lovelace/dashboard";
import { DEFAULT_PANEL } from "../../../../data/panel";
import { DEFAULT_PANEL, setDefaultPanel } from "../../../../data/panel";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showConfirmationDialog } from "../../../lovelace/custom-card-helpers";
import type { LovelaceDashboardDetailsDialogParams } from "./show-dialog-lovelace-dashboard-detail";
@customElement("dialog-lovelace-dashboard-detail")
@@ -61,8 +59,7 @@ export class DialogLovelaceDashboardDetail extends LitElement {
if (!this._params || !this._data) {
return nothing;
}
const defaultPanelUrlPath =
this.hass.systemData?.defaultPanel || DEFAULT_PANEL;
const defaultPanelUrlPath = this.hass.defaultPanel;
const titleInvalid = !this._data.title || !this._data.title.trim();
return html`
@@ -254,38 +251,15 @@ export class DialogLovelaceDashboardDetail extends LitElement {
};
}
private async _toggleDefault() {
private _toggleDefault() {
const urlPath = this._params?.urlPath;
if (!urlPath) {
return;
}
const defaultPanel = this.hass.systemData?.defaultPanel || DEFAULT_PANEL;
// Add warning dialog to saying that this will change the default dashboard for all users
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
urlPath === defaultPanel
? "ui.panel.config.lovelace.dashboards.detail.remove_default_confirm_title"
: "ui.panel.config.lovelace.dashboards.detail.set_default_confirm_title"
),
text: this.hass.localize(
urlPath === defaultPanel
? "ui.panel.config.lovelace.dashboards.detail.remove_default_confirm_text"
: "ui.panel.config.lovelace.dashboards.detail.set_default_confirm_text"
),
confirmText: this.hass.localize("ui.common.ok"),
dismissText: this.hass.localize("ui.common.cancel"),
destructive: false,
});
if (!confirm) {
return;
}
saveFrontendSystemData(this.hass.connection, "core", {
...this.hass.systemData,
defaultPanel: urlPath === defaultPanel ? undefined : urlPath,
});
setDefaultPanel(
this,
urlPath === this.hass.defaultPanel ? DEFAULT_PANEL : urlPath
);
}
private async _updateDashboard() {

View File

@@ -45,7 +45,6 @@ import {
fetchDashboards,
updateDashboard,
} from "../../../../data/lovelace/dashboard";
import { DEFAULT_PANEL } from "../../../../data/panel";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
import "../../../../layouts/hass-loading-screen";
import "../../../../layouts/hass-tabs-subpage-data-table";
@@ -287,7 +286,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
);
private _getItems = memoize(
(dashboards: LovelaceDashboard[], defaultUrlPath: string | null) => {
(dashboards: LovelaceDashboard[], defaultUrlPath: string) => {
const defaultMode = (
this.hass.panels?.lovelace?.config as LovelacePanelConfig
).mode;
@@ -404,8 +403,6 @@ export class HaConfigLovelaceDashboards extends LitElement {
return html` <hass-loading-screen></hass-loading-screen> `;
}
const defaultPanel = this.hass.systemData?.defaultPanel || DEFAULT_PANEL;
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
@@ -419,7 +416,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
this._dashboards,
this.hass.localize
)}
.data=${this._getItems(this._dashboards, defaultPanel)}
.data=${this._getItems(this._dashboards, this.hass.defaultPanel)}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}

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,6 +1,5 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
import {
findEntities,
generateEntityFilter,
@@ -11,7 +10,12 @@ import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import {
computeAreaTileCardConfig,
getAreas,
getFloors,
} from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import { getHomeStructure } from "../../lovelace/strategies/home/helpers/home-structure";
export interface LightViewStrategyConfig {
type: "light";
@@ -81,9 +85,9 @@ export class LightViewStrategy extends ReactiveElement {
_config: LightViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const areas = Object.values(hass.areas);
const floors = Object.values(hass.floors);
const hierarchy = getAreasFloorHierarchy(floors, areas);
const areas = getAreas(hass.areas);
const floors = getFloors(hass.floors);
const home = getHomeStructure(floors, areas);
const sections: LovelaceSectionRawConfig[] = [];
@@ -95,11 +99,10 @@ export class LightViewStrategy extends ReactiveElement {
const entities = findEntities(allEntities, lightFilters);
const floorCount =
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
const floorCount = home.floors.length + (home.areas.length ? 1 : 0);
// Process floors
for (const floorStructure of hierarchy.floors) {
for (const floorStructure of home.floors) {
const floorId = floorStructure.id;
const areaIds = floorStructure.areas;
const floor = hass.floors[floorId];
@@ -128,7 +131,7 @@ export class LightViewStrategy extends ReactiveElement {
}
// Process unassigned areas
if (hierarchy.areas.length > 0) {
if (home.areas.length > 0) {
const section: LovelaceSectionRawConfig = {
type: "grid",
column_span: 2,
@@ -143,7 +146,7 @@ export class LightViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForLight(hierarchy.areas, hass, entities);
const areaCards = processAreasForLight(home.areas, hass, entities);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);

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

@@ -124,24 +124,16 @@ class HuiHistoryChartCardFeature
}
const hourToShow = this._config.hours_to_show ?? DEFAULT_HOURS_TO_SHOW;
const detail = this._config.detail !== false; // default to true (high detail)
return subscribeHistoryStatesTimeWindow(
this.hass!,
(historyStates) => {
// sample to 1 point per hour for low detail or 1 point per 5 pixels for high detail
const maxDetails = detail
? Math.max(10, this.clientWidth / 5, hourToShow)
: Math.max(10, hourToShow);
const useMean = !detail;
const { points, yAxisOrigin } =
coordinatesMinimalResponseCompressedState(
historyStates[this.context!.entity_id!],
this.clientWidth,
this.clientHeight,
maxDetails,
undefined,
useMean
this.clientWidth / 5 // sample to 1 point per 5 pixels
);
this._coordinates = points;
this._yAxisOrigin = yAxisOrigin;

View File

@@ -199,7 +199,6 @@ export interface UpdateActionsCardFeatureConfig {
export interface TrendGraphCardFeatureConfig {
type: "trend-graph";
hours_to_show?: number;
detail?: boolean;
}
export const AREA_CONTROLS = [
@@ -227,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

@@ -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

@@ -20,10 +20,6 @@ const SCHEMA = [
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
},
{
name: "detail",
selector: { boolean: {} },
},
] as const satisfies HaFormSchema[];
@customElement("hui-trend-graph-card-feature-editor")
@@ -52,10 +48,6 @@ export class HuiTrendGraphCardFeatureEditor
data.hours_to_show = DEFAULT_HOURS_TO_SHOW;
}
if (this._config.detail === undefined) {
data.detail = true;
}
return html`
<ha-form
.hass=${this.hass}
@@ -77,10 +69,6 @@ export class HuiTrendGraphCardFeatureEditor
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
case "detail":
return this.hass!.localize(
"ui.panel.lovelace.editor.features.types.trend-graph.detail"
);
default:
return "";
}

View File

@@ -3,21 +3,20 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-md-dialog";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import "../../../../components/ha-md-select";
import "../../../../components/ha-md-select-option";
import "../../../../components/ha-button";
import "../../../../components/ha-spinner";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelaceDashboard } from "../../../../data/lovelace/dashboard";
import { fetchDashboards } from "../../../../data/lovelace/dashboard";
import { getDefaultPanelUrlPath } from "../../../../data/panel";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { SelectDashboardDialogParams } from "./show-select-dashboard-dialog";
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
@customElement("hui-dialog-select-dashboard")
export class HuiDialogSelectDashboard extends LitElement {
@@ -144,9 +143,7 @@ export class HuiDialogSelectDashboard extends LitElement {
...(this._params!.dashboards || (await fetchDashboards(this.hass))),
];
const defaultPanel = getDefaultPanelUrlPath(this.hass);
const currentPath = this._fromUrlPath || defaultPanel;
const currentPath = this._fromUrlPath || this.hass.defaultPanel;
for (const dashboard of this._dashboards!) {
if (dashboard.url_path !== currentPath) {
this._toUrlPath = dashboard.url_path;

View File

@@ -16,7 +16,6 @@ import { fetchConfig } from "../../../../data/lovelace/config/types";
import { isStrategyView } from "../../../../data/lovelace/config/view";
import type { LovelaceDashboard } from "../../../../data/lovelace/dashboard";
import { fetchDashboards } from "../../../../data/lovelace/dashboard";
import { getDefaultPanelUrlPath } from "../../../../data/panel";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { SelectViewDialogParams } from "./show-select-view-dialog";
@@ -61,9 +60,6 @@ export class HuiDialogSelectView extends LitElement {
if (!this._params) {
return nothing;
}
const defaultPanel = getDefaultPanelUrlPath(this.hass);
return html`
<ha-dialog
open
@@ -80,7 +76,7 @@ export class HuiDialogSelectView extends LitElement {
"ui.panel.lovelace.editor.select_view.dashboard_label"
)}
.disabled=${!this._dashboards.length}
.value=${this._urlPath || defaultPanel}
.value=${this._urlPath || this.hass.defaultPanel}
@selected=${this._dashboardChanged}
@closed=${stopPropagation}
fixedMenuPosition

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

@@ -0,0 +1,39 @@
import type { AreaRegistryEntry } from "../../../../../data/area_registry";
import type { FloorRegistryEntry } from "../../../../../data/floor_registry";
interface HomeStructure {
floors: {
id: string;
areas: string[];
}[];
areas: string[];
}
export const getHomeStructure = (
floors: FloorRegistryEntry[],
areas: AreaRegistryEntry[]
): HomeStructure => {
const floorAreas = new Map<string, string[]>();
const unassignedAreas: string[] = [];
for (const area of areas) {
if (area.floor_id) {
if (!floorAreas.has(area.floor_id)) {
floorAreas.set(area.floor_id, []);
}
floorAreas.get(area.floor_id)!.push(area.area_id);
} else {
unassignedAreas.push(area.area_id);
}
}
const homeStructure: HomeStructure = {
floors: floors.map((floor) => ({
id: floor.floor_id,
areas: floorAreas.get(floor.floor_id) || [],
})),
areas: unassignedAreas,
};
return homeStructure;
};

View File

@@ -4,6 +4,7 @@ import { customElement } from "lit/decorators";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
import { getAreas } from "../areas/helpers/areas-strategy-helper";
import type { LovelaceStrategyEditor } from "../types";
import {
getSummaryLabel,
@@ -45,7 +46,7 @@ export class HomeDashboardStrategy extends ReactiveElement {
};
}
const areas = Object.values(hass.areas);
const areas = getAreas(hass.areas);
const areaViews = areas.map<LovelaceViewRawConfig>((area) => {
const path = `areas-${area.area_id}`;

View File

@@ -22,8 +22,9 @@ import type {
MarkdownCardConfig,
WeatherForecastCardConfig,
} from "../../cards/types";
import { getAreas, getFloors } from "../areas/helpers/areas-strategy-helper";
import type { CommonControlSectionStrategyConfig } from "../usage_prediction/common-controls-section-strategy";
import { getAreasFloorHierarchy } from "../../../../common/areas/areas-floor-hierarchy";
import { getHomeStructure } from "./helpers/home-structure";
import { HOME_SUMMARIES_FILTERS } from "./helpers/home-summaries";
export interface HomeMainViewStrategyConfig {
@@ -63,10 +64,10 @@ export class HomeMainViewStrategy extends ReactiveElement {
config: HomeMainViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const areas = Object.values(hass.areas);
const floors = Object.values(hass.floors);
const areas = getAreas(hass.areas);
const floors = getFloors(hass.floors);
const home = getAreasFloorHierarchy(floors, areas);
const home = getHomeStructure(floors, areas);
const floorCount = home.floors.length + (home.areas.length ? 1 : 0);

View File

@@ -10,7 +10,8 @@ import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
import type { MediaControlCardConfig } from "../../cards/types";
import { getAreasFloorHierarchy } from "../../../../common/areas/areas-floor-hierarchy";
import { getAreas, getFloors } from "../areas/helpers/areas-strategy-helper";
import { getHomeStructure } from "./helpers/home-structure";
import { HOME_SUMMARIES_FILTERS } from "./helpers/home-summaries";
export interface HomeMediaPlayersViewStrategyConfig {
@@ -84,9 +85,9 @@ export class HomeMMediaPlayersViewStrategy extends ReactiveElement {
_config: HomeMediaPlayersViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const areas = Object.values(hass.areas);
const floors = Object.values(hass.floors);
const home = getAreasFloorHierarchy(floors, areas);
const areas = getAreas(hass.areas);
const floors = getFloors(hass.floors);
const home = getHomeStructure(floors, areas);
const sections: LovelaceSectionRawConfig[] = [];

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

@@ -6,10 +6,8 @@ import "../../components/ha-select";
import "../../components/ha-settings-row";
import type { LovelaceDashboard } from "../../data/lovelace/dashboard";
import { fetchDashboards } from "../../data/lovelace/dashboard";
import { setDefaultPanel } from "../../data/panel";
import type { HomeAssistant } from "../../types";
import { saveFrontendUserData } from "../../data/frontend";
const USE_SYSTEM_VALUE = "___use_system___";
@customElement("ha-pick-dashboard-row")
class HaPickDashboardRow extends LitElement {
@@ -25,7 +23,6 @@ class HaPickDashboardRow extends LitElement {
}
protected render(): TemplateResult {
const value = this.hass.userData?.defaultPanel || USE_SYSTEM_VALUE;
return html`
<ha-settings-row .narrow=${this.narrow}>
<span slot="heading">
@@ -40,18 +37,14 @@ class HaPickDashboardRow extends LitElement {
"ui.panel.profile.dashboard.dropdown_label"
)}
.disabled=${!this._dashboards?.length}
.value=${value}
.value=${this.hass.defaultPanel}
@selected=${this._dashboardChanged}
naturalMenuWidth
>
<ha-list-item .value=${USE_SYSTEM_VALUE}>
${this.hass.localize("ui.panel.profile.dashboard.system")}
</ha-list-item>
<ha-list-item value="lovelace">
${this.hass.localize("ui.panel.profile.dashboard.lovelace")}
</ha-list-item>
<ha-list-item value="home">
${this.hass.localize("ui.panel.profile.dashboard.home")}
${this.hass.localize(
"ui.panel.profile.dashboard.default_dashboard_label"
)}
</ha-list-item>
${this._dashboards.map((dashboard) => {
if (!this.hass.user!.is_admin && dashboard.require_admin) {
@@ -79,18 +72,11 @@ class HaPickDashboardRow extends LitElement {
}
private _dashboardChanged(ev) {
const value = ev.target.value as string;
if (!value) {
const urlPath = ev.target.value;
if (!urlPath || urlPath === this.hass.defaultPanel) {
return;
}
const urlPath = value === USE_SYSTEM_VALUE ? undefined : value;
if (urlPath === this.hass.userData?.defaultPanel) {
return;
}
saveFrontendUserData(this.hass.connection, "core", {
...this.hass.userData,
defaultPanel: urlPath,
});
setDefaultPanel(this, urlPath);
}
}

View File

@@ -154,10 +154,6 @@ class HaProfileSectionGeneral extends LitElement {
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-first-weekday-row>
<ha-pick-dashboard-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-dashboard-row>
<ha-settings-row .narrow=${this.narrow}>
<span slot="heading">
${this.hass.localize(
@@ -212,6 +208,10 @@ class HaProfileSectionGeneral extends LitElement {
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-theme-row>
<ha-pick-dashboard-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-dashboard-row>
${this.hass.dockedSidebar !== "auto" || !this.narrow
? html`
<ha-force-narrow-row

View File

@@ -1,6 +1,5 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
import {
findEntities,
generateEntityFilter,
@@ -11,7 +10,12 @@ import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import {
computeAreaTileCardConfig,
getAreas,
getFloors,
} from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import { getHomeStructure } from "../../lovelace/strategies/home/helpers/home-structure";
export interface SecurityViewStrategyConfig {
type: "security";
@@ -123,9 +127,9 @@ export class SecurityViewStrategy extends ReactiveElement {
_config: SecurityViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const areas = Object.values(hass.areas);
const floors = Object.values(hass.floors);
const hierarchy = getAreasFloorHierarchy(floors, areas);
const areas = getAreas(hass.areas);
const floors = getFloors(hass.floors);
const home = getHomeStructure(floors, areas);
const sections: LovelaceSectionRawConfig[] = [];
@@ -137,11 +141,10 @@ export class SecurityViewStrategy extends ReactiveElement {
const entities = findEntities(allEntities, securityFilters);
const floorCount =
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
const floorCount = home.floors.length + (home.areas.length ? 1 : 0);
// Process floors
for (const floorStructure of hierarchy.floors) {
for (const floorStructure of home.floors) {
const floorId = floorStructure.id;
const areaIds = floorStructure.areas;
const floor = hass.floors[floorId];
@@ -170,7 +173,7 @@ export class SecurityViewStrategy extends ReactiveElement {
}
// Process unassigned areas
if (hierarchy.areas.length > 0) {
if (home.areas.length > 0) {
const section: LovelaceSectionRawConfig = {
type: "grid",
column_span: 2,
@@ -185,11 +188,7 @@ export class SecurityViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForSecurity(
hierarchy.areas,
hass,
entities
);
const areaCards = processAreasForSecurity(home.areas, hass, entities);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);

View File

@@ -8,16 +8,13 @@ import {
subscribeServices,
} from "home-assistant-js-websocket";
import { fireEvent } from "../common/dom/fire_event";
import { computeStateName } from "../common/entity/compute_state_name";
import { promiseTimeout } from "../common/util/promise-timeout";
import { subscribeAreaRegistry } from "../data/area_registry";
import { broadcastConnectionStatus } from "../data/connection-status";
import { subscribeDeviceRegistry } from "../data/device_registry";
import {
subscribeFrontendSystemData,
subscribeFrontendUserData,
} from "../data/frontend";
import { subscribeFrontendUserData } from "../data/frontend";
import { forwardHaptic } from "../data/haptics";
import { DEFAULT_PANEL } from "../data/panel";
import { serviceCallWillDisconnect } from "../data/service";
import {
DateFormat,
@@ -36,6 +33,7 @@ import { fetchWithAuth } from "../util/fetch-with-auth";
import { getState } from "../util/ha-pref-storage";
import hassCallApi, { hassCallApiRaw } from "../util/hass-call-api";
import type { HassBaseEl } from "./hass-base-mixin";
import { computeStateName } from "../common/entity/compute_state_name";
export const connectionMixin = <T extends Constructor<HassBaseEl>>(
superClass: T
@@ -61,9 +59,8 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
panels: null as any,
services: null as any,
user: null as any,
userData: undefined,
systemData: undefined,
panelUrl: (this as any)._panelUrl,
defaultPanel: DEFAULT_PANEL,
language,
selectedLanguage: null,
locale: {
@@ -76,6 +73,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
},
resources: null as any,
localize: () => "",
translationMetadata,
dockedSidebar: "docked",
vibrate: true,
@@ -211,9 +209,9 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
formatEntityAttributeName: (_stateObj, attribute) => attribute,
formatEntityAttributeValue: (stateObj, attribute, value) =>
value != null ? value : (stateObj.attributes[attribute] ?? ""),
formatEntityName: (stateObj) => computeStateName(stateObj),
...getState(),
...this._pendingHass,
formatEntityName: (stateObj) => computeStateName(stateObj),
};
this.hassConnected();
@@ -284,26 +282,10 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
subscribeConfig(conn, (config) => this._updateHass({ config }));
subscribeServices(conn, (services) => this._updateHass({ services }));
subscribePanels(conn, (panels) => this._updateHass({ panels }));
// Catch errors to userData and systemData subscription (e.g. if the
// backend isn't up to date) and set to null so frontend can continue
subscribeFrontendUserData(conn, "core", ({ value: userData }) =>
this._updateHass({ userData: userData || {} })
).catch(() => {
// eslint-disable-next-line no-console
console.error(
"Failed to subscribe to user data, setting to empty object"
);
this._updateHass({ userData: {} });
});
subscribeFrontendSystemData(conn, "core", ({ value: systemData }) =>
this._updateHass({ systemData: systemData || {} })
).catch(() => {
// eslint-disable-next-line no-console
console.error(
"Failed to subscribe to system data, setting to empty object"
);
this._updateHass({ systemData: {} });
subscribeFrontendUserData(conn, "core", ({ value: userData }) => {
this._updateHass({ userData });
});
clearInterval(this.__backendPingInterval);
this.__backendPingInterval = setInterval(() => {
if (this.hass?.connected) {

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

@@ -7,14 +7,20 @@ interface DockSidebarParams {
dock: HomeAssistant["dockedSidebar"];
}
interface DefaultPanelParams {
defaultPanel: HomeAssistant["defaultPanel"];
}
declare global {
// for fire event
interface HASSDomEvents {
"hass-dock-sidebar": DockSidebarParams;
"hass-default-panel": DefaultPanelParams;
}
// for add event listener
interface HTMLElementEventMap {
"hass-dock-sidebar": HASSDomEvent<DockSidebarParams>;
"hass-default-panel": HASSDomEvent<DefaultPanelParams>;
}
}
@@ -26,5 +32,9 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
this._updateHass({ dockedSidebar: ev.detail.dock });
storeState(this.hass!);
});
this.addEventListener("hass-default-panel", (ev) => {
this._updateHass({ defaultPanel: ev.detail.defaultPanel });
storeState(this.hass!);
});
}
};

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%]",
@@ -2457,10 +2456,7 @@
"delete_floor": "Delete floor",
"confirm_delete": "Delete floor?",
"confirm_delete_text": "Deleting the floor will unassign all areas from it."
},
"area_reorder_failed": "Failed to reorder areas",
"area_move_failed": "Failed to move area",
"floor_reorder_failed": "Failed to reorder floors"
}
},
"editor": {
"create_area": "Create area",
@@ -3519,12 +3515,8 @@
"delete": "Delete",
"update": "Update",
"create": "Create",
"set_default": "Set as default",
"remove_default": "Remove as default",
"set_default_confirm_title": "Set as default dashboard?",
"set_default_confirm_text": "This will replace the current default dashboard. Users can still override their default dashboard in their profile settings.",
"remove_default_confirm_title": "Remove default dashboard?",
"remove_default_confirm_text": "The default dashboard will be changed to Overview for every user. Users can still override their default dashboard in their profile settings."
"set_default": "Set as default on this device",
"remove_default": "Remove as default on this device"
}
},
"resources": {
@@ -6768,6 +6760,7 @@
},
"analytics": {
"caption": "Analytics",
"header": "Home Assistant analytics",
"description": "Learn how to share data to improve Home Assistant",
"preferences": {
"base": {
@@ -6785,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"
},
@@ -7174,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}",
@@ -8432,13 +8432,10 @@
"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",
"detail": "Show more detail"
"label": "Trend graph"
}
}
},
@@ -8674,11 +8671,9 @@
},
"dashboard": {
"header": "Dashboard",
"description": "Pick a default dashboard to show.",
"description": "Pick a default dashboard for this device.",
"dropdown_label": "Dashboard",
"lovelace": "Overview",
"home": "Home",
"system": "Auto (use system settings)"
"default_dashboard_label": "Overview (default)"
},
"change_password": {
"header": "Change password",
@@ -9439,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": {
@@ -9468,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

@@ -18,10 +18,7 @@ import type { AreaRegistryEntry } from "./data/area_registry";
import type { DeviceRegistryEntry } from "./data/device_registry";
import type { EntityRegistryDisplayEntry } from "./data/entity_registry";
import type { FloorRegistryEntry } from "./data/floor_registry";
import type {
CoreFrontendSystemData,
CoreFrontendUserData,
} from "./data/frontend";
import type { CoreFrontendUserData } from "./data/frontend";
import type {
FrontendLocaleData,
getHassTranslations,
@@ -251,10 +248,10 @@ export interface HomeAssistant {
vibrate: boolean;
debugConnection: boolean;
dockedSidebar: "docked" | "always_hidden" | "auto";
defaultPanel: string;
moreInfoEntityId: string | null;
user?: CurrentUser;
userData?: CoreFrontendUserData;
systemData?: CoreFrontendSystemData;
userData?: CoreFrontendUserData | null;
hassUrl(path?): string;
callService<T = any>(
domain: ServiceCallRequest["domain"],

View File

@@ -8,9 +8,8 @@ const STORED_STATE = [
"debugConnection",
"suspendWhenHidden",
"enableShortcuts",
] as const;
type StoredHomeAssistant = Pick<HomeAssistant, (typeof STORED_STATE)[number]>;
"defaultPanel",
];
export function storeState(hass: HomeAssistant) {
try {
@@ -32,8 +31,8 @@ export function storeState(hass: HomeAssistant) {
}
}
export function getState(): Partial<StoredHomeAssistant> {
const state = {} as Partial<StoredHomeAssistant>;
export function getState() {
const state = {};
STORED_STATE.forEach((key) => {
const storageItem = window.localStorage.getItem(key);

View File

@@ -24,7 +24,7 @@ describe("ha-pref-storage", () => {
window.localStorage.setItem = vi.fn();
storeState(mockHass as unknown as HomeAssistant);
expect(window.localStorage.setItem).toHaveBeenCalledTimes(7);
expect(window.localStorage.setItem).toHaveBeenCalledTimes(8);
expect(window.localStorage.setItem).toHaveBeenCalledWith(
"dockedSidebar",
JSON.stringify("auto")

View File

@@ -1282,15 +1282,15 @@ __metadata:
languageName: node
linkType: hard
"@codemirror/view@npm:6.38.8, @codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0":
version: 6.38.8
resolution: "@codemirror/view@npm:6.38.8"
"@codemirror/view@npm:6.38.6, @codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0":
version: 6.38.6
resolution: "@codemirror/view@npm:6.38.6"
dependencies:
"@codemirror/state": "npm:^6.5.0"
crelt: "npm:^1.0.6"
style-mod: "npm:^4.1.0"
w3c-keyname: "npm:^2.2.4"
checksum: 10/81b1508015a378e4719d0239254173f0c5cd340c2abf96eb488fe5fb474bdb37ec1f010b9890ced774accd7aeb9443e7337cb6a89544b954273e5ddabece7cea
checksum: 10/5a047337a98de111817ce8c8d39e6429c90ca0b0a4d2678d6e161e9e5961b1d476a891f447ab7a05cac395d4a93530e7c68bedd93191285265f0742a308ad00b
languageName: node
linkType: hard
@@ -8869,25 +8869,25 @@ __metadata:
languageName: node
linkType: hard
"glob@npm:12.0.0":
version: 12.0.0
resolution: "glob@npm:12.0.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/6e21b3f1f1fa635836d45e54bbe50704884cc3e310e0cc011cfb5429db65a030e12936d99b07e66236370efe45dc8c8b26fa5334dbf555d6f8709e0315c77c30
checksum: 10/2ae536c1360c0266b523b2bfa6aadc10144a8b7e08869b088e37ac3c27cd30774f82e4bfb291cde796776e878f9e13200c7ff44010eb7054e00f46f649397893
languageName: node
linkType: hard
"glob@npm:^10.5.0":
version: 10.5.0
resolution: "glob@npm:10.5.0"
"glob@npm:^10.2.2":
version: 10.4.5
resolution: "glob@npm:10.4.5"
dependencies:
foreground-child: "npm:^3.1.0"
jackspeak: "npm:^3.1.2"
@@ -8897,7 +8897,7 @@ __metadata:
path-scurry: "npm:^1.11.1"
bin:
glob: dist/esm/bin.mjs
checksum: 10/ab3bccfefcc0afaedbd1f480cd0c4a2c0e322eb3f0aa7ceaa31b3f00b825069f17cf0f1fc8b6f256795074b903f37c0ade37ddda6a176aa57f1c2bbfe7240653
checksum: 10/698dfe11828b7efd0514cd11e573eaed26b2dff611f0400907281ce3eab0c1e56143ef9b35adc7c77ecc71fba74717b510c7c223d34ca8a98ec81777b293d4ac
languageName: node
linkType: hard
@@ -9208,7 +9208,7 @@ __metadata:
"@codemirror/legacy-modes": "npm:6.5.2"
"@codemirror/search": "npm:6.5.11"
"@codemirror/state": "npm:6.5.2"
"@codemirror/view": "npm:6.38.8"
"@codemirror/view": "npm:6.38.6"
"@date-fns/tz": "npm:1.4.1"
"@egjs/hammerjs": "npm:2.0.17"
"@formatjs/intl-datetimeformat": "npm:6.18.2"
@@ -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:12.0.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