Compare commits

...

7 Commits

Author SHA1 Message Date
Paul Bottein
5ce6419cd8 Add sections for entities and helpers 2025-11-25 14:53:49 +01:00
Paul Bottein
ff28ca11c9 Add unassigned devices view to home dashboard 2025-11-25 11:50:30 +01:00
Aidan Timson
82a3db39fe Use entity name for activity/logbook renderer (#28083) 2025-11-25 12:46:57 +02:00
Paul Bottein
254857b53f Improve sidebar and dashboard management logic (#28019)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-11-25 10:39:07 +00:00
Simon Lamon
1648be6b83 Rename DialogLovelaceDashboardConfigureStrategy (#28092) 2025-11-25 12:22:18 +02:00
Jan Layola
c4c774c217 Add total consumption to energy usage graph card (#28086)
* Add total consumption to energy usage graph card

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

* Apply code review feedback

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

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-25 10:18:29 +00:00
Bram Kragten
6a0aab2088 Add dynamic condition support (#28058)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-25 11:17:53 +01:00
34 changed files with 1726 additions and 499 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -56,12 +56,19 @@ import {
type AutomationElementGroup,
type AutomationElementGroupCollection,
} from "../../../data/automation";
import type { ConditionDescriptions } from "../../../data/condition";
import {
CONDITION_BUILDING_BLOCKS_GROUP,
CONDITION_COLLECTIONS,
CONDITION_ICONS,
getConditionDomain,
getConditionObjectId,
subscribeConditions,
} from "../../../data/condition";
import { getServiceIcons, getTriggerIcons } from "../../../data/icons";
import {
getConditionIcons,
getServiceIcons,
getTriggerIcons,
} from "../../../data/icons";
import type { IntegrationManifest } from "../../../data/integration";
import {
domainToName,
@@ -82,6 +89,7 @@ import { isMac } from "../../../util/is_mac";
import { showToast } from "../../../util/toast";
import type { AddAutomationElementDialogParams } from "./show-add-automation-element-dialog";
import { PASTE_VALUE } from "./show-add-automation-element-dialog";
import { CONDITION_ICONS } from "../../../components/ha-condition-icon";
const TYPES = {
trigger: { collections: TRIGGER_COLLECTIONS, icons: TRIGGER_ICONS },
@@ -119,7 +127,7 @@ const ENTITY_DOMAINS_OTHER = new Set([
const ENTITY_DOMAINS_MAIN = new Set(["notify"]);
const ACTION_SERVICE_KEYWORDS = ["dynamicGroups", "helpers", "other"];
const DYNAMIC_KEYWORDS = ["dynamicGroups", "helpers", "other"];
@customElement("add-automation-element-dialog")
class DialogAddAutomationElement
@@ -152,6 +160,8 @@ class DialogAddAutomationElement
@state() private _triggerDescriptions: TriggerDescriptions = {};
@state() private _conditionDescriptions: ConditionDescriptions = {};
@query(".items ha-md-list ha-md-list-item")
private _itemsListFirstElement?: HaMdList;
@@ -169,15 +179,15 @@ class DialogAddAutomationElement
this.addKeyboardShortcuts();
this._unsubscribe();
this._fetchManifests();
if (this._params?.type === "action") {
this.hass.loadBackendTranslation("services");
this._fetchManifests();
this._calculateUsedDomains();
getServiceIcons(this.hass);
}
if (this._params?.type === "trigger") {
} else if (this._params?.type === "trigger") {
this.hass.loadBackendTranslation("triggers");
this._fetchManifests();
getTriggerIcons(this.hass);
this._unsub = subscribeTriggers(this.hass, (triggers) => {
this._triggerDescriptions = {
@@ -185,7 +195,17 @@ class DialogAddAutomationElement
...triggers,
};
});
} else if (this._params?.type === "condition") {
this.hass.loadBackendTranslation("conditions");
getConditionIcons(this.hass);
this._unsub = subscribeConditions(this.hass, (conditions) => {
this._conditionDescriptions = {
...this._conditionDescriptions,
...conditions,
};
});
}
this._fullScreen = matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
@@ -199,10 +219,7 @@ class DialogAddAutomationElement
public closeDialog() {
this.removeKeyboardShortcuts();
if (this._unsub) {
this._unsub.then((unsub) => unsub());
this._unsub = undefined;
}
this._unsubscribe();
if (this._params) {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -219,6 +236,13 @@ class DialogAddAutomationElement
return true;
}
private _unsubscribe() {
if (this._unsub) {
this._unsub.then((unsub) => unsub());
this._unsub = undefined;
}
}
private _getGroups = (
type: AddAutomationElementDialogParams["type"],
group?: string,
@@ -348,8 +372,11 @@ class DialogAddAutomationElement
items.push(
...this._triggers(localize, this._triggerDescriptions, manifests)
);
}
if (type === "action") {
} else if (type === "condition") {
items.push(
...this._conditions(localize, this._conditionDescriptions, manifests)
);
} else if (type === "action") {
items.push(...this._services(localize, services, manifests));
}
return items;
@@ -372,6 +399,7 @@ class DialogAddAutomationElement
localize: LocalizeFunc,
services: HomeAssistant["services"],
triggerDescriptions: TriggerDescriptions,
conditionDescriptions: ConditionDescriptions,
manifests?: DomainManifestLookup
): {
titleKey?: LocalizeKeys;
@@ -383,35 +411,10 @@ class DialogAddAutomationElement
let collectionGroups = Object.entries(collection.groups);
const groups: ListItem[] = [];
if (
type === "action" &&
Object.keys(collection.groups).some((item) =>
ACTION_SERVICE_KEYWORDS.includes(item)
)
) {
groups.push(
...this._serviceGroups(
localize,
services,
manifests,
domains,
collection.groups.dynamicGroups
? undefined
: collection.groups.helpers
? "helper"
: "other"
)
);
collectionGroups = collectionGroups.filter(
([key]) => !ACTION_SERVICE_KEYWORDS.includes(key)
);
}
if (
type === "trigger" &&
Object.keys(collection.groups).some((item) =>
ACTION_SERVICE_KEYWORDS.includes(item)
DYNAMIC_KEYWORDS.includes(item)
)
) {
groups.push(
@@ -429,7 +432,53 @@ class DialogAddAutomationElement
);
collectionGroups = collectionGroups.filter(
([key]) => !ACTION_SERVICE_KEYWORDS.includes(key)
([key]) => !DYNAMIC_KEYWORDS.includes(key)
);
} else if (
type === "condition" &&
Object.keys(collection.groups).some((item) =>
DYNAMIC_KEYWORDS.includes(item)
)
) {
groups.push(
...this._conditionGroups(
localize,
conditionDescriptions,
manifests,
domains,
collection.groups.dynamicGroups
? undefined
: collection.groups.helpers
? "helper"
: "other"
)
);
collectionGroups = collectionGroups.filter(
([key]) => !DYNAMIC_KEYWORDS.includes(key)
);
} else if (
type === "action" &&
Object.keys(collection.groups).some((item) =>
DYNAMIC_KEYWORDS.includes(item)
)
) {
groups.push(
...this._serviceGroups(
localize,
services,
manifests,
domains,
collection.groups.dynamicGroups
? undefined
: collection.groups.helpers
? "helper"
: "other"
)
);
collectionGroups = collectionGroups.filter(
([key]) => !DYNAMIC_KEYWORDS.includes(key)
);
}
@@ -487,10 +536,6 @@ class DialogAddAutomationElement
services: HomeAssistant["services"],
manifests?: DomainManifestLookup
): ListItem[] => {
if (type === "action" && isDynamic(group)) {
return this._services(localize, services, manifests, group);
}
if (type === "trigger" && isDynamic(group)) {
return this._triggers(
localize,
@@ -499,6 +544,17 @@ class DialogAddAutomationElement
group
);
}
if (type === "condition" && isDynamic(group)) {
return this._conditions(
localize,
this._conditionDescriptions,
manifests,
group
);
}
if (type === "action" && isDynamic(group)) {
return this._services(localize, services, manifests, group);
}
const groups = this._getGroups(type, group, collectionIndex);
@@ -688,6 +744,102 @@ class DialogAddAutomationElement
}
);
private _conditionGroups = (
localize: LocalizeFunc,
conditions: ConditionDescriptions,
manifests: DomainManifestLookup | undefined,
domains: Set<string> | undefined,
type: "helper" | "other" | undefined
): ListItem[] => {
if (!conditions || !manifests) {
return [];
}
const result: ListItem[] = [];
const addedDomains = new Set<string>();
Object.keys(conditions).forEach((condition) => {
const domain = getConditionDomain(condition);
if (addedDomains.has(domain)) {
return;
}
addedDomains.add(domain);
const manifest = manifests[domain];
const domainUsed = !domains ? true : domains.has(domain);
if (
(type === undefined &&
(ENTITY_DOMAINS_MAIN.has(domain) ||
(manifest?.integration_type === "entity" &&
domainUsed &&
!ENTITY_DOMAINS_OTHER.has(domain)))) ||
(type === "helper" && manifest?.integration_type === "helper") ||
(type === "other" &&
!ENTITY_DOMAINS_MAIN.has(domain) &&
(ENTITY_DOMAINS_OTHER.has(domain) ||
(!domainUsed && manifest?.integration_type === "entity") ||
!["helper", "entity"].includes(manifest?.integration_type || "")))
) {
result.push({
icon: html`
<ha-domain-icon
.hass=${this.hass}
.domain=${domain}
brand-fallback
></ha-domain-icon>
`,
key: `${DYNAMIC_PREFIX}${domain}`,
name: domainToName(localize, domain, manifest),
description: "",
});
}
});
return result.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
);
};
private _conditions = memoizeOne(
(
localize: LocalizeFunc,
conditions: ConditionDescriptions,
_manifests: DomainManifestLookup | undefined,
group?: string
): ListItem[] => {
if (!conditions) {
return [];
}
const result: ListItem[] = [];
for (const condition of Object.keys(conditions)) {
const domain = getConditionDomain(condition);
const conditionName = getConditionObjectId(condition);
if (group && group !== `${DYNAMIC_PREFIX}${domain}`) {
continue;
}
result.push({
icon: html`
<ha-condition-icon
.hass=${this.hass}
.condition=${condition}
></ha-condition-icon>
`,
key: `${DYNAMIC_PREFIX}${condition}`,
name:
localize(`component.${domain}.conditions.${conditionName}.name`) ||
condition,
description:
localize(
`component.${domain}.conditions.${conditionName}.description`
) || condition,
});
}
return result;
}
);
private _services = memoizeOne(
(
localize: LocalizeFunc,
@@ -832,6 +984,7 @@ class DialogAddAutomationElement
this.hass.localize,
this.hass.services,
this._triggerDescriptions,
this._conditionDescriptions,
this._manifests
);
@@ -1136,6 +1289,7 @@ class DialogAddAutomationElement
super.disconnectedCallback();
window.removeEventListener("resize", this._updateNarrow);
this._removeSearchKeybindings();
this._unsubscribe();
}
private _updateNarrow = () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import { formatTimeWithSeconds } from "../../common/datetime/format_time";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { DEFAULT_ENTITY_NAME } from "../../common/entity/compute_entity_name_display";
import { navigate } from "../../common/navigate";
import { computeTimelineColor } from "../../components/chart/timeline-color";
import "../../components/entity/state-badge";
@@ -463,15 +464,24 @@ class HaLogbookRenderer extends LitElement {
entityName: string | undefined,
noLink?: boolean
) {
const hasState = entityId && entityId in this.hass.states;
const displayName =
entityName ||
(hasState
? this.hass.states[entityId].attributes.friendly_name || entityId
: entityId);
if (!entityId) {
return entityName || "";
}
const stateObj = this.hass.states[entityId];
const hasState = Boolean(stateObj);
const displayName = hasState
? this.hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME) ||
entityName ||
stateObj.attributes.friendly_name ||
entityId
: entityName || entityId;
if (!hasState) {
return displayName;
}
return noLink
? displayName
: html`<button

View File

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

View File

@@ -35,6 +35,7 @@ const COLORS: Record<HomeSummary, string> = {
climate: "deep-orange",
security: "blue-grey",
media_players: "blue",
unassigned_devices: "grey",
};
@customElement("hui-home-summary-card")

View File

@@ -50,6 +50,8 @@ const STRATEGIES: Record<LovelaceStrategyConfigType, Record<string, any>> = {
"home-media-players": () =>
import("./home/home-media-players-view-strategy"),
"home-area": () => import("./home/home-area-view-strategy"),
"home-unassigned-devices": () =>
import("./home/home-unassigned-devices-view-strategy"),
light: () => import("../../light/strategies/light-view-strategy"),
security: () => import("../../security/strategies/security-view-strategy"),
climate: () => import("../../climate/strategies/climate-view-strategy"),

View File

@@ -9,6 +9,7 @@ export const HOME_SUMMARIES = [
"climate",
"security",
"media_players",
"unassigned_devices",
] as const;
export type HomeSummary = (typeof HOME_SUMMARIES)[number];
@@ -18,6 +19,7 @@ export const HOME_SUMMARIES_ICONS: Record<HomeSummary, string> = {
climate: "mdi:home-thermometer",
security: "mdi:security",
media_players: "mdi:multimedia",
unassigned_devices: "mdi:shape",
};
export const HOME_SUMMARIES_FILTERS: Record<HomeSummary, EntityFilter[]> = {
@@ -25,6 +27,19 @@ export const HOME_SUMMARIES_FILTERS: Record<HomeSummary, EntityFilter[]> = {
climate: climateEntityFilters,
security: securityEntityFilters,
media_players: [{ domain: "media_player", entity_category: "none" }],
unassigned_devices: [
{
area: null,
hidden_platform: [
"automation",
"script",
"hassio",
"backup",
"zone",
"person",
],
},
],
};
export const getSummaryLabel = (

View File

@@ -71,6 +71,16 @@ export class HomeDashboardStrategy extends ReactiveElement {
icon: HOME_SUMMARIES_ICONS.media_players,
} satisfies LovelaceViewRawConfig;
const unassignedDevicesView = {
title: getSummaryLabel(hass.localize, "unassigned_devices"),
path: "unassigned-devices",
subview: true,
strategy: {
type: "home-unassigned-devices",
},
icon: HOME_SUMMARIES_ICONS.unassigned_devices,
} satisfies LovelaceViewRawConfig;
return {
views: [
{
@@ -83,6 +93,7 @@ export class HomeDashboardStrategy extends ReactiveElement {
},
...areaViews,
mediaPlayersView,
unassignedDevicesView,
],
};
}

View File

@@ -228,6 +228,19 @@ export class HomeMainViewStrategy extends ReactiveElement {
columns: 4,
},
} satisfies HomeSummaryCard),
{
type: "home-summary",
summary: "unassigned_devices",
vertical: true,
tap_action: {
action: "navigate",
navigation_path: "unassigned-devices",
},
grid_options: {
rows: 2,
columns: 4,
},
} satisfies HomeSummaryCard,
].filter(Boolean) as LovelaceCardConfig[];
const summarySection: LovelaceSectionConfig = {
@@ -297,6 +310,29 @@ export class HomeMainViewStrategy extends ReactiveElement {
}
}
const noAreaFilter = generateEntityFilter(hass, {
area: null,
});
const otherEntities = allEntities.filter(noAreaFilter);
if (otherEntities.length > 0) {
widgetSection.cards!.push({
type: "tile",
entity: otherEntities[0],
icon: "mdi:shape",
name: "Unassigned devices",
hide_state: true,
tap_action: {
action: "navigate",
navigation_path: "unassigned-devices",
},
icon_tap_action: {
action: "none",
},
});
}
const sections = (
[
favoriteSection.cards && favoriteSection,

View File

@@ -0,0 +1,205 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
import {
findEntities,
generateEntityFilter,
} from "../../../../common/entity/entity_filter";
import { clamp } from "../../../../common/number/clamp";
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
import { isHelperDomain } from "../../../config/helpers/const";
import type { HeadingCardConfig } from "../../cards/types";
import { HOME_SUMMARIES_FILTERS } from "./helpers/home-summaries";
export interface HomeUnassignedDevicesViewStrategyConfig {
type: "home-unassigned-devices";
}
@customElement("home-unassigned-devices-view-strategy")
export class HomeUnassignedDevicesViewStrategy extends ReactiveElement {
static async generate(
_config: HomeUnassignedDevicesViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const allEntities = Object.keys(hass.states);
const unassignedFilters = HOME_SUMMARIES_FILTERS.unassigned_devices.map(
(filter) => generateEntityFilter(hass, filter)
);
const unassignedEntities = findEntities(allEntities, unassignedFilters);
const sections: LovelaceSectionRawConfig[] = [];
const entitiesByDevice: Record<string, string[]> = {};
const entitiesWithoutDevices: string[] = [];
for (const entityId of unassignedEntities) {
const stateObj = hass.states[entityId];
if (!stateObj) continue;
const { device } = getEntityContext(
stateObj,
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
if (!device) {
entitiesWithoutDevices.push(entityId);
continue;
}
if (!(device.id in entitiesByDevice)) {
entitiesByDevice[device.id] = [];
}
entitiesByDevice[device.id].push(entityId);
}
const devicesEntities = Object.entries(entitiesByDevice).map(
([deviceId, entities]) => ({
device_id: deviceId,
entities: entities,
})
);
const helpersEntities = entitiesWithoutDevices.filter((entityId) => {
const domain = entityId.split(".")[0];
return isHelperDomain(domain);
});
const otherEntities = entitiesWithoutDevices.filter((entityId) => {
const domain = entityId.split(".")[0];
return !isHelperDomain(domain);
});
const batteryFilter = generateEntityFilter(hass, {
domain: "sensor",
device_class: "battery",
});
const energyFilter = generateEntityFilter(hass, {
domain: "sensor",
device_class: ["energy", "power"],
});
const primaryFilter = generateEntityFilter(hass, {
entity_category: "none",
});
for (const deviceEntities of devicesEntities) {
if (deviceEntities.entities.length === 0) continue;
const batteryEntities = deviceEntities.entities.filter((e) =>
batteryFilter(e)
);
const entities = deviceEntities.entities.filter(
(e) => !batteryFilter(e) && !energyFilter(e) && primaryFilter(e)
);
if (entities.length === 0) {
continue;
}
const deviceId = deviceEntities.device_id;
const device = hass.devices[deviceId];
let heading = "";
if (device) {
heading =
computeDeviceName(device) ||
hass.localize("ui.panel.lovelace.strategy.home.unamed_device");
}
sections.push({
type: "grid",
cards: [
{
type: "heading",
heading: heading,
tap_action: device
? {
action: "navigate",
navigation_path: `/config/devices/device/${device.id}`,
}
: undefined,
badges: [
...batteryEntities.slice(0, 1).map((e) => ({
entity: e,
type: "entity",
tap_action: {
action: "more-info",
},
})),
],
} satisfies HeadingCardConfig,
...entities.map((e) => ({
type: "tile",
entity: e,
name: {
type: "entity",
},
})),
],
});
}
if (helpersEntities.length) {
sections.push({
type: "grid",
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.unassigned_devices.unassigned_helpers"
),
} satisfies HeadingCardConfig,
...helpersEntities.map((e) => ({
type: "tile",
entity: e,
})),
],
});
}
if (otherEntities.length) {
sections.push({
type: "grid",
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.unassigned_devices.unassigned_entities"
),
} satisfies HeadingCardConfig,
...otherEntities.map((e) => ({
type: "tile",
entity: e,
})),
],
});
}
// Allow between 2 and 3 columns (the max should be set to define the width of the header)
const maxColumns = clamp(sections.length, 2, 3);
// Take the full width if there is only one section to avoid narrow header on desktop
if (sections.length === 1) {
sections[0].column_span = 2;
}
return {
type: "sections",
header: {
badges_position: "bottom",
},
max_columns: maxColumns,
sections: sections,
};
}
}
declare global {
interface HTMLElementTagNameMap {
"home-unassigned-devices-view-strategy": HomeUnassignedDevicesViewStrategy;
}
}

View File

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

View File

@@ -2217,7 +2217,9 @@
"sidebar_toggle": "Sidebar toggle",
"edit_sidebar": "Edit sidebar",
"edit_subtitle": "Synced on all devices",
"migrate_to_user_data": "This will change the sidebar on all the devices you are logged in to. To create a sidebar per device, you should use a different user for that device."
"migrate_to_user_data": "This will change the sidebar on all the devices you are logged in to. To create a sidebar per device, you should use a different user for that device.",
"reset_to_defaults": "Reset to defaults",
"reset_confirmation": "Are you sure you want to reset the sidebar to its default configuration? This will restore the original order and visibility of all panels."
},
"panel": {
"home": {
@@ -3508,6 +3510,7 @@
"edit": "Edit",
"delete": "Delete",
"add_dashboard": "Add dashboard",
"set_as_default": "Set as default",
"type": {
"user_created": "User created",
"built_in": "Built-in"
@@ -3516,7 +3519,7 @@
"confirm_delete_title": "Delete {dashboard_title}?",
"confirm_delete_text": "This dashboard will be permanently deleted.",
"cant_edit_yaml": "Dashboards created in YAML cannot be edited from the UI. Change them in configuration.yaml.",
"cant_edit_default": "The default dashboard, Overview, cannot be edited from the UI. You can hide it by setting another dashboard as default.",
"cant_edit_lovelace": "The Overview dashboard title and icon cannot be changed. You can create a new dashboard to get more customization options.",
"detail": {
"edit_dashboard": "Edit dashboard",
"new_dashboard": "Add new dashboard",
@@ -3533,9 +3536,7 @@
"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_confirm_text": "This dashboard will be shown to all users when opening Home Assistant. Each user can change this in their profile."
}
},
"resources": {
@@ -7062,7 +7063,8 @@
},
"home": {
"summary_list": {
"media_players": "Media players"
"media_players": "Media players",
"unassigned_devices": "Unassigned devices"
},
"welcome_user": "Welcome {user}",
"summaries": "Summaries",
@@ -7092,6 +7094,10 @@
"home_media_players": {
"media_players": "Media players",
"other_media_players": "Other media players"
},
"unassigned_devices": {
"unassigned_helpers": "Unassigned helpers",
"unassigned_entities": "Unassigned entities"
}
},
"cards": {
@@ -7158,6 +7164,7 @@
"energy_usage_graph": {
"total_consumed": "Total consumed {num} kWh",
"total_returned": "Total returned {num} kWh",
"total_usage": "{num} kWh used",
"combined_from_grid": "Combined from grid",
"consumed_solar": "Consumed solar",
"consumed_battery": "Consumed battery"