Add area dashboard settings (#24619)

* Add hidden and order settings

* Share path logic

* Add editor to sort and filter areas

* Remove unused form

* Add areas strategy in the dashboard picker

* Move display editor

* Fix min width

* Add leading icon slot to expansion panel

* Fix left chevron icon with dynamic property

* Use area display in original state strategy

* Rename selector

* Rename to area_display

* Remove ha-expansion-panel changes

* Remove expanded

* Fix rebase

* Display all entities in the areas strategy overview (#24663)

* Don't use subgroup

* Add all entities in the overview

* Add tile card features for area view
This commit is contained in:
Paul Bottein 2025-03-17 22:04:01 +01:00 committed by GitHub
parent 7aaea37db7
commit 64b9104199
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 779 additions and 595 deletions

View File

@ -0,0 +1,30 @@
import type { AreaRegistryEntry } from "../../../data/area_registry";
import type { FloorRegistryEntry } from "../../../data/floor_registry";
import type { HomeAssistant } from "../../../types";
interface AreaContext {
area: AreaRegistryEntry | null;
floor: FloorRegistryEntry | null;
}
export const getAreaContext = (
areaId: string,
hass: HomeAssistant
): AreaContext => {
const area = (hass.areas[areaId] as AreaRegistryEntry | undefined) || null;
if (!area) {
return {
area: null,
floor: null,
};
}
const floorId = area?.floor_id;
const floor = floorId ? hass.floors[floorId] : null;
return {
area: area,
floor: floor,
};
};

View File

@ -17,7 +17,7 @@ export interface EntityFilter {
hidden_platform?: string | string[];
}
type EntityFilterFunc = (entityId: string) => boolean;
export type EntityFilterFunc = (entityId: string) => boolean;
export const generateEntityFilter = (
hass: HomeAssistant,

View File

@ -1,95 +0,0 @@
import { mdiTextureBox } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { showAreaFilterDialog } from "../dialogs/area-filter/show-area-filter-dialog";
import type { HomeAssistant } from "../types";
import "./ha-icon-next";
import "./ha-svg-icon";
import "./ha-textfield";
export interface AreaFilterValue {
hidden?: string[];
order?: string[];
}
@customElement("ha-area-filter")
export class HaAreaPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ attribute: false }) public value?: AreaFilterValue;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
protected render(): TemplateResult {
const allAreasCount = Object.keys(this.hass.areas).length;
const hiddenAreasCount = this.value?.hidden?.length ?? 0;
const description =
hiddenAreasCount === 0
? this.hass.localize("ui.components.area-filter.all_areas")
: allAreasCount === hiddenAreasCount
? this.hass.localize("ui.components.area-filter.no_areas")
: this.hass.localize("ui.components.area-filter.area_count", {
count: allAreasCount - hiddenAreasCount,
});
return html`
<ha-list-item
tabindex="0"
role="button"
hasMeta
twoline
graphic="icon"
@click=${this._edit}
@keydown=${this._edit}
.disabled=${this.disabled}
>
<ha-svg-icon slot="graphic" .path=${mdiTextureBox}></ha-svg-icon>
<span>${this.label}</span>
<span slot="secondary">${description}</span>
<ha-icon-next
slot="meta"
.label=${this.hass.localize("ui.common.edit")}
></ha-icon-next>
</ha-list-item>
`;
}
private async _edit(ev) {
if (ev.defaultPrevented) {
return;
}
if (ev.type === "keydown" && ev.key !== "Enter" && ev.key !== " ") {
return;
}
ev.preventDefault();
ev.stopPropagation();
const value = await showAreaFilterDialog(this, {
title: this.label,
initialValue: this.value,
});
if (!value) return;
fireEvent(this, "value-changed", { value });
}
static styles = css`
ha-list-item {
--mdc-list-side-padding-left: 8px;
--mdc-list-side-padding-right: 8px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-area-filter": HaAreaPicker;
}
}

View File

@ -0,0 +1,98 @@
import { mdiTextureBox } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { getAreaContext } from "../common/entity/context/get_area_context";
import { areaCompare } from "../data/area_registry";
import type { HomeAssistant } from "../types";
import "./ha-expansion-panel";
import "./ha-items-display-editor";
import type { DisplayItem, DisplayValue } from "./ha-items-display-editor";
import "./ha-svg-icon";
import "./ha-textfield";
export interface AreasDisplayValue {
hidden?: string[];
order?: string[];
}
@customElement("ha-areas-display-editor")
export class HaAreasDisplayEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ attribute: false }) public value?: AreasDisplayValue;
@property() public helper?: string;
@property({ type: Boolean }) public expanded = false;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
protected render(): TemplateResult {
const compare = areaCompare(this.hass.areas, this.value?.order);
const areas = Object.values(this.hass.areas).sort((areaA, areaB) =>
compare(areaA.area_id, areaB.area_id)
);
const items: DisplayItem[] = areas.map((area) => {
const { floor } = getAreaContext(area.area_id, this.hass!);
return {
value: area.area_id,
label: area.name,
icon: area.icon ?? undefined,
iconPath: mdiTextureBox,
description: floor?.name,
};
});
const value: DisplayValue = {
order: this.value?.order ?? [],
hidden: this.value?.hidden ?? [],
};
return html`
<ha-expansion-panel
outlined
.header=${this.label}
.expanded=${this.expanded}
>
<ha-svg-icon slot="leading-icon" .path=${mdiTextureBox}></ha-svg-icon>
<ha-items-display-editor
.hass=${this.hass}
.items=${items}
.value=${value}
@value-changed=${this._areaDisplayChanged}
></ha-items-display-editor>
</ha-expansion-panel>
`;
}
private async _areaDisplayChanged(ev) {
ev.stopPropagation();
const value = ev.detail.value as DisplayValue;
const newValue: AreasDisplayValue = {
...this.value,
...value,
};
if (newValue.hidden?.length === 0) {
delete newValue.hidden;
}
if (newValue.order?.length === 0) {
delete newValue.order;
}
fireEvent(this, "value-changed", { value: newValue });
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-areas-display-editor": HaAreasDisplayEditor;
}
}

View File

@ -0,0 +1,241 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { mdiDrag, mdiEye, mdiEyeOff } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-icon-button-next";
import "./ha-md-list";
import "./ha-md-list-item";
import "./ha-sortable";
import "./ha-svg-icon";
export interface DisplayItem {
icon?: string;
iconPath?: string;
value: string;
label: string;
description?: string;
}
export interface DisplayValue {
order: string[];
hidden: string[];
}
declare global {
interface HTMLElementTagNameMap {
"ha-items-display-editor": HaItemDisplayEditor;
}
interface HASSDomEvents {
"item-display-navigate-clicked": { value: string };
}
}
@customElement("ha-items-display-editor")
export class HaItemDisplayEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public items: DisplayItem[] = [];
@property({ type: Boolean, attribute: "show-navigation-button" })
public showNavigationButton = false;
@property({ attribute: false })
public value: DisplayValue = {
order: [],
hidden: [],
};
private _showIcon = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect.width > 450,
});
private _toggle(ev) {
ev.stopPropagation();
const value = ev.currentTarget.value;
const hiddenItems = this._hiddenItems(this.items, this.value.hidden);
const newHidden = hiddenItems.map((item) => item.value);
if (newHidden.includes(value)) {
newHidden.splice(newHidden.indexOf(value), 1);
} else {
newHidden.push(value);
}
const newVisibleItems = this._visibleItems(this.items, newHidden);
const newOrder = newVisibleItems.map((a) => a.value);
this.value = {
hidden: newHidden,
order: newOrder,
};
fireEvent(this, "value-changed", { value: this.value });
}
private _itemMoved(ev: CustomEvent): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const visibleItems = this._visibleItems(this.items, this.value.hidden);
const newOrder = visibleItems.map((item) => item.value);
const movedItem = newOrder.splice(oldIndex, 1)[0];
newOrder.splice(newIndex, 0, movedItem);
this.value = {
...this.value,
order: newOrder,
};
fireEvent(this, "value-changed", { value: this.value });
}
private _navigate(ev) {
const value = ev.currentTarget.value;
fireEvent(this, "item-display-navigate-clicked", { value });
ev.stopPropagation();
}
private _visibleItems = memoizeOne((items: DisplayItem[], hidden: string[]) =>
items.filter((item) => !hidden.includes(item.value))
);
private _hiddenItems = memoizeOne((items: DisplayItem[], hidden: string[]) =>
items.filter((item) => hidden.includes(item.value))
);
protected render() {
const allItems = [
...this._visibleItems(this.items, this.value.hidden),
...this._hiddenItems(this.items, this.value.hidden),
];
const showIcon = this._showIcon.value;
return html`
<ha-sortable
draggable-selector=".draggable"
handle-selector=".handle"
@item-moved=${this._itemMoved}
>
<ha-md-list>
${repeat(
allItems,
(item) => item.value,
(item, _idx) => {
const isVisible = !this.value.hidden.includes(item.value);
const { label, value, description, icon, iconPath } = item;
return html`
<ha-md-list-item
class=${classMap({
hidden: !isVisible,
draggable: isVisible,
})}
>
<span slot="headline">${label}</span>
${description
? html`<span slot="supporting-text">${description}</span>`
: nothing}
${isVisible
? html`
<ha-svg-icon
class="handle"
.path=${mdiDrag}
slot="start"
></ha-svg-icon>
`
: html`<ha-svg-icon slot="start"></ha-svg-icon>`}
${!showIcon
? nothing
: icon
? html`
<ha-icon
class="icon"
.icon=${icon}
slot="start"
></ha-icon>
`
: iconPath
? html`
<ha-svg-icon
class="icon"
.path=${iconPath}
slot="start"
></ha-svg-icon>
`
: 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>
${this.showNavigationButton
? html`
<ha-icon-button-next
slot="end"
.value=${value}
@click=${this._navigate}
></ha-icon-button-next>
`
: nothing}
</ha-md-list-item>
`;
}
)}
</ha-md-list>
</ha-sortable>
`;
}
static styles = css`
:host {
display: block;
}
.handle {
cursor: move;
padding: 8px;
margin: -8px;
}
ha-md-list {
padding: 0;
}
ha-md-list-item {
--md-list-item-top-space: 0;
--md-list-item-bottom-space: 0;
--md-list-item-leading-space: 8px;
--md-list-item-trailing-space: 8px;
--md-list-item-two-line-container-height: 48px;
--md-list-item-one-line-container-height: 48px;
}
ha-md-list-item ha-icon-button {
margin-left: -12px;
margin-right: -12px;
}
ha-md-list-item.hidden {
--md-list-item-label-text-color: var(--disabled-text-color);
--md-list-item-supporting-text-color: var(--disabled-text-color);
}
ha-md-list-item.hidden .icon {
color: var(--disabled-text-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-items-display-editor": HaItemDisplayEditor;
}
}

View File

@ -1,14 +1,14 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators";
import type { AreaFilterSelector } from "../../data/selector";
import type { AreasDisplaySelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-area-filter";
import "../ha-areas-display-editor";
@customElement("ha-selector-area_filter")
export class HaAreaFilterSelector extends LitElement {
@customElement("ha-selector-areas_display")
export class HaAreasDisplaySelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: AreaFilterSelector;
@property({ attribute: false }) public selector!: AreasDisplaySelector;
@property() public value?: any;
@ -22,20 +22,20 @@ export class HaAreaFilterSelector extends LitElement {
protected render() {
return html`
<ha-area-filter
<ha-areas-display-editor
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
></ha-area-filter>
></ha-areas-display-editor>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-area_filter": HaAreaFilterSelector;
"ha-selector-areas_display": HaAreasDisplaySelector;
}
}

View File

@ -14,7 +14,7 @@ const LOAD_ELEMENTS = {
action: () => import("./ha-selector-action"),
addon: () => import("./ha-selector-addon"),
area: () => import("./ha-selector-area"),
area_filter: () => import("./ha-selector-area-filter"),
areas_display: () => import("./ha-selector-areas-display"),
attribute: () => import("./ha-selector-attribute"),
assist_pipeline: () => import("./ha-selector-assist-pipeline"),
boolean: () => import("./ha-selector-boolean"),

View File

@ -23,7 +23,7 @@ export type Selector =
| ActionSelector
| AddonSelector
| AreaSelector
| AreaFilterSelector
| AreasDisplaySelector
| AttributeSelector
| BooleanSelector
| ButtonToggleSelector
@ -92,8 +92,8 @@ export interface AreaSelector {
} | null;
}
export interface AreaFilterSelector {
area_filter: {} | null;
export interface AreasDisplaySelector {
areas_display: {} | null;
}
export interface AttributeSelector {

View File

@ -1,203 +0,0 @@
import "@material/mwc-list/mwc-list";
import { mdiDrag, mdiEye, mdiEyeOff } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../common/dom/fire_event";
import type { AreaFilterValue } from "../../components/ha-area-filter";
import "../../components/ha-button";
import "../../components/ha-dialog";
import "../../components/ha-icon-button";
import "../../components/ha-list-item";
import "../../components/ha-sortable";
import { areaCompare } from "../../data/area_registry";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { HassDialog } from "../make-dialog-manager";
import type { AreaFilterDialogParams } from "./show-area-filter-dialog";
@customElement("dialog-area-filter")
export class DialogAreaFilter
extends LitElement
implements HassDialog<AreaFilterDialogParams>
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _dialogParams?: AreaFilterDialogParams;
@state() private _hidden: string[] = [];
@state() private _areas: string[] = [];
public showDialog(dialogParams: AreaFilterDialogParams): void {
this._dialogParams = dialogParams;
this._hidden = dialogParams.initialValue?.hidden ?? [];
const order = dialogParams.initialValue?.order ?? [];
const allAreas = Object.keys(this.hass!.areas);
this._areas = allAreas.concat().sort(areaCompare(this.hass!.areas, order));
}
public closeDialog() {
this._dialogParams = undefined;
this._hidden = [];
this._areas = [];
fireEvent(this, "dialog-closed", { dialog: this.localName });
return true;
}
private _submit(): void {
const order = this._areas.filter((area) => !this._hidden.includes(area));
const value: AreaFilterValue = {
hidden: this._hidden,
order,
};
this._dialogParams?.submit?.(value);
this.closeDialog();
}
private _cancel(): void {
this._dialogParams?.cancel?.();
this.closeDialog();
}
private _areaMoved(ev: CustomEvent): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const areas = this._areas.concat();
const option = areas.splice(oldIndex, 1)[0];
areas.splice(newIndex, 0, option);
this._areas = areas;
}
protected render() {
if (!this._dialogParams || !this.hass) {
return nothing;
}
const allAreas = this._areas;
return html`
<ha-dialog
open
@closed=${this._cancel}
.heading=${this._dialogParams.title ??
this.hass.localize("ui.components.area-filter.title")}
>
<ha-sortable
draggable-selector=".draggable"
handle-selector=".handle"
@item-moved=${this._areaMoved}
>
<mwc-list class="areas">
${repeat(
allAreas,
(area) => area,
(area, _idx) => {
const isVisible = !this._hidden.includes(area);
const name = this.hass!.areas[area]?.name || area;
return html`
<ha-list-item
class=${classMap({
hidden: !isVisible,
draggable: isVisible,
})}
hasMeta
graphic="icon"
noninteractive
>
${isVisible
? html`<ha-svg-icon
class="handle"
.path=${mdiDrag}
slot="graphic"
></ha-svg-icon>`
: nothing}
${name}
<ha-icon-button
tabindex="0"
class="action"
.path=${isVisible ? mdiEye : mdiEyeOff}
slot="meta"
.label=${this.hass!.localize(
`ui.components.area-filter.${
isVisible ? "hide" : "show"
}`,
{ area: name }
)}
.area=${area}
@click=${this._toggle}
></ha-icon-button>
</ha-list-item>
`;
}
)}
</mwc-list>
</ha-sortable>
<ha-button slot="secondaryAction" dialogAction="cancel">
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button @click=${this._submit} slot="primaryAction">
${this.hass.localize("ui.common.submit")}
</ha-button>
</ha-dialog>
`;
}
private _toggle(ev) {
const area = ev.target.area;
const hidden = [...(this._hidden ?? [])];
if (hidden.includes(area)) {
hidden.splice(hidden.indexOf(area), 1);
} else {
hidden.push(area);
}
this._hidden = hidden;
const nonHiddenAreas = this._areas.filter(
(ar) => !this._hidden.includes(ar)
);
const hiddenAreas = this._areas.filter((ar) => this._hidden.includes(ar));
this._areas = [...nonHiddenAreas, ...hiddenAreas];
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-dialog {
/* Place above other dialogs */
--dialog-z-index: 104;
--dialog-content-padding: 0;
}
ha-list-item {
overflow: visible;
}
.hidden {
color: var(--disabled-text-color);
}
.handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
}
.actions {
display: flex;
flex-direction: row;
}
ha-icon-button {
display: block;
margin: -12px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-area-filter": DialogAreaFilter;
}
}

View File

@ -1,38 +0,0 @@
import { fireEvent } from "../../common/dom/fire_event";
import type { AreaFilterValue } from "../../components/ha-area-filter";
export interface AreaFilterDialogParams {
title?: string;
initialValue?: AreaFilterValue;
submit?: (value?: AreaFilterValue) => void;
cancel?: () => void;
}
export const showAreaFilterDialog = (
element: HTMLElement,
dialogParams: AreaFilterDialogParams
) =>
new Promise<AreaFilterValue | null>((resolve) => {
const origCancel = dialogParams.cancel;
const origSubmit = dialogParams.submit;
fireEvent(element, "show-dialog", {
dialogTag: "dialog-area-filter",
dialogImport: () => import("./area-filter-dialog"),
dialogParams: {
...dialogParams,
cancel: () => {
resolve(null);
if (origCancel) {
origCancel();
}
},
submit: (code: AreaFilterValue) => {
resolve(code);
if (origSubmit) {
origSubmit(code);
}
},
},
});
});

View File

@ -1,5 +1,5 @@
import "@material/mwc-list/mwc-list";
import { mdiMap, mdiPencilOutline, mdiShape, mdiWeb } from "@mdi/js";
import { mdiHome, mdiMap, mdiPencilOutline, mdiShape, mdiWeb } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@ -30,6 +30,10 @@ const STRATEGIES = [
type: "iframe",
iconPath: mdiWeb,
},
{
type: "areas",
iconPath: mdiHome,
},
] as const satisfies Strategy[];
@customElement("ha-dialog-new-dashboard")

View File

@ -7,7 +7,7 @@ import { splitByGroups } from "../../../common/entity/split_by_groups";
import { stripPrefixFromEntityName } from "../../../common/entity/strip_prefix_from_entity_name";
import { stringCompare } from "../../../common/string/compare";
import type { LocalizeFunc } from "../../../common/translations/localize";
import type { AreaFilterValue } from "../../../components/ha-area-filter";
import type { AreasDisplayValue } from "../../../components/ha-areas-display-editor";
import { areaCompare } from "../../../data/area_registry";
import type {
EnergyPreferences,
@ -503,7 +503,7 @@ export const generateDefaultViewConfig = (
entities: HassEntities,
localize: LocalizeFunc,
energyPrefs?: EnergyPreferences,
areasPrefs?: AreaFilterValue,
areasPrefs?: AreasDisplayValue,
hideEntitiesWithoutAreas?: boolean,
hideEnergy?: boolean
): LovelaceViewConfig => {

View File

@ -191,8 +191,24 @@ class DialogDashboardStrategyEditor extends LitElement {
haStyleDialog,
css`
ha-dialog {
--mdc-dialog-max-width: 800px;
--dialog-content-padding: 0 24px;
--dialog-surface-position: fixed;
--dialog-surface-top: 40px;
--mdc-dialog-min-width: min(600px, calc(100% - 32px));
--mdc-dialog-max-width: calc(100% - 32px);
--mdc-dialog-max-height: calc(100% - 80px);
}
@media all and (max-width: 450px), all and (max-height: 500px) {
/* overrule the ha-style-dialog max-height on small screens */
ha-dialog {
height: 100%;
--dialog-surface-top: 0px;
--mdc-dialog-min-width: 100%;
--mdc-dialog-max-width: 100%;
--mdc-dialog-max-height: 100%;
--dialog-content-padding: 8px;
}
}
`,
];

View File

@ -14,7 +14,7 @@ const SCHEMA = [
{
name: "areas",
selector: {
area_filter: {},
areas_display: {},
},
},
{

View File

@ -1,30 +1,173 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import type { EntityFilterFunc } from "../../../../common/entity/entity_filter";
import { generateEntityFilter } from "../../../../common/entity/entity_filter";
import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
import { supportsAlarmModesCardFeature } from "../../card-features/hui-alarm-modes-card-feature";
import { supportsCoverOpenCloseCardFeature } from "../../card-features/hui-cover-open-close-card-feature";
import { supportsLightBrightnessCardFeature } from "../../card-features/hui-light-brightness-card-feature";
import { supportsLockCommandsCardFeature } from "../../card-features/hui-lock-commands-card-feature";
import { supportsTargetTemperatureCardFeature } from "../../card-features/hui-target-temperature-card-feature";
import type { LovelaceCardFeatureConfig } from "../../card-features/types";
type Group = "lights" | "climate" | "media_players" | "security";
type AreaEntitiesByGroup = Record<Group, string[]>;
type AreaFilteredByGroup = Record<Group, EntityFilterFunc[]>;
export const getAreaGroupedEntities = (
area: string,
hass: HomeAssistant,
controlOnly = false
): AreaEntitiesByGroup => {
const allEntities = Object.keys(hass.states);
const groupedFilters: AreaFilteredByGroup = {
lights: [
generateEntityFilter(hass, {
domain: "light",
area: area,
entity_category: "none",
}),
],
climate: [
generateEntityFilter(hass, {
domain: "climate",
area: area,
entity_category: "none",
}),
generateEntityFilter(hass, {
domain: "humidifier",
area: area,
entity_category: "none",
}),
generateEntityFilter(hass, {
domain: "cover",
area: area,
device_class: [
"shutter",
"awning",
"blind",
"curtain",
"shade",
"shutter",
"window",
],
entity_category: "none",
}),
...(controlOnly
? []
: [
generateEntityFilter(hass, {
domain: "binary_sensor",
area: area,
device_class: "window",
entity_category: "none",
}),
]),
],
media_players: [
generateEntityFilter(hass, {
domain: "media_player",
area: area,
entity_category: "none",
}),
],
security: [
generateEntityFilter(hass, {
domain: "alarm_control_panel",
area: area,
entity_category: "none",
}),
generateEntityFilter(hass, {
domain: "lock",
area: area,
entity_category: "none",
}),
generateEntityFilter(hass, {
domain: "cover",
device_class: ["door", "garage", "gate"],
area: area,
entity_category: "none",
}),
...(controlOnly
? []
: [
generateEntityFilter(hass, {
domain: "binary_sensor",
device_class: ["door", "garage_door"],
area: area,
entity_category: "none",
}),
]),
],
};
return Object.fromEntries(
Object.entries(groupedFilters).map(([group, filters]) => [
group,
filters.reduce<string[]>(
(acc, filter) => [
...acc,
...allEntities.filter((entity) => filter(entity)),
],
[]
),
])
) as AreaEntitiesByGroup;
};
export interface AreaViewStrategyConfig {
type: "area";
area?: string;
}
const computeTileCard = (entity: string): LovelaceCardConfig => ({
type: "tile",
entity: entity,
});
const computeTileCardConfig =
(hass: HomeAssistant) =>
(entity: string): LovelaceCardConfig => {
const stateObj = hass.states[entity];
let feature: LovelaceCardFeatureConfig | undefined;
if (supportsLightBrightnessCardFeature(stateObj)) {
feature = {
type: "light-brightness",
};
} else if (supportsCoverOpenCloseCardFeature(stateObj)) {
feature = {
type: "cover-open-close",
};
} else if (supportsTargetTemperatureCardFeature(stateObj)) {
feature = {
type: "target-temperature",
};
} else if (supportsAlarmModesCardFeature(stateObj)) {
feature = {
type: "alarm-modes",
};
} else if (supportsLockCommandsCardFeature(stateObj)) {
feature = {
type: "lock-commands",
};
}
return {
type: "tile",
entity: entity,
features: feature ? [feature] : undefined,
};
};
const computeHeadingCard = (
heading: string,
icon: string,
style: "title" | "subtitle" = "title"
icon: string
): LovelaceCardConfig => ({
type: "heading",
heading: heading,
heading_style: style,
icon: icon,
});
@ -64,134 +207,36 @@ export class AreaViewStrategy extends ReactiveElement {
});
}
const allEntities = Object.keys(hass.states);
const groupedEntities = getAreaGroupedEntities(config.area, hass);
// Lights
const lights = allEntities.filter(
generateEntityFilter(hass, {
domain: "light",
area: config.area,
entity_category: "none",
})
);
const computeTileCard = computeTileCardConfig(hass);
if (lights.length) {
const {
lights,
climate,
media_players: mediaPlayers,
security,
} = groupedEntities;
if (lights.length > 0) {
sections.push({
type: "grid",
cards: [
{
type: "heading",
heading: "Lights",
icon: "mdi:lamps",
},
...lights.map((entity) => ({
type: "tile",
entity: entity,
})),
computeHeadingCard("Lights", "mdi:lightbulb"),
...lights.map(computeTileCard),
],
});
}
// Climate
const thermostats = allEntities.filter(
generateEntityFilter(hass, {
domain: "climate",
area: config.area,
entity_category: "none",
})
);
const humidifiers = allEntities.filter(
generateEntityFilter(hass, {
domain: "humidifier",
area: config.area,
entity_category: "none",
})
);
const shutters = allEntities.filter(
generateEntityFilter(hass, {
domain: "cover",
area: config.area,
device_class: [
"shutter",
"awning",
"blind",
"curtain",
"shade",
"shutter",
"window",
],
entity_category: "none",
})
);
const climateSensor = allEntities.filter(
generateEntityFilter(hass, {
domain: "binary_sensor",
area: config.area,
device_class: "window",
entity_category: "none",
})
);
const climateSectionCards: LovelaceCardConfig[] = [];
if (
thermostats.length ||
humidifiers.length ||
shutters.length ||
climateSensor.length
) {
climateSectionCards.push(
computeHeadingCard("Climate", "mdi:home-thermometer")
);
}
if (thermostats.length > 0 || humidifiers.length > 0) {
const title =
thermostats.length > 0 && humidifiers.length
? "Thermostats and humidifiers"
: thermostats.length
? "Thermostats"
: "Humidifiers";
climateSectionCards.push(
computeHeadingCard(title, "mdi:thermostat", "subtitle"),
...thermostats.map(computeTileCard),
...humidifiers.map(computeTileCard)
);
}
if (shutters.length > 0) {
climateSectionCards.push(
computeHeadingCard("Shutters", "mdi:window-shutter", "subtitle"),
...shutters.map(computeTileCard)
);
}
if (climateSensor.length > 0) {
climateSectionCards.push(
computeHeadingCard("Sensors", "mdi:window-open", "subtitle"),
...climateSensor.map(computeTileCard)
);
}
if (climateSectionCards.length > 0) {
if (climate.length > 0) {
sections.push({
type: "grid",
cards: climateSectionCards,
cards: [
computeHeadingCard("Climate", "mdi:home-thermometer"),
...climate.map(computeTileCard),
],
});
}
// Media players
const mediaPlayers = allEntities.filter(
generateEntityFilter(hass, {
domain: "media_player",
area: config.area,
entity_category: "none",
})
);
if (mediaPlayers.length > 0) {
sections.push({
type: "grid",
@ -202,77 +247,27 @@ export class AreaViewStrategy extends ReactiveElement {
});
}
// Security
const alarms = allEntities.filter(
generateEntityFilter(hass, {
domain: "alarm_control_panel",
area: config.area,
entity_category: "none",
})
);
const locks = allEntities.filter(
generateEntityFilter(hass, {
domain: "lock",
area: config.area,
entity_category: "none",
})
);
const doors = allEntities.filter(
generateEntityFilter(hass, {
domain: "cover",
device_class: ["door", "garage", "gate"],
area: config.area,
entity_category: "none",
})
);
const securitySensors = allEntities.filter(
generateEntityFilter(hass, {
domain: "binary_sensor",
device_class: ["door", "garage_door"],
area: config.area,
entity_category: "none",
})
);
const securitySectionCards: LovelaceCardConfig[] = [];
if (alarms.length > 0 || locks.length > 0) {
const title =
alarms.length > 0 && locks.length
? "Alarms and locks"
: alarms.length
? "Alarms"
: "Locks";
securitySectionCards.push(
computeHeadingCard(title, "mdi:shield", "subtitle"),
...alarms.map(computeTileCard),
...locks.map(computeTileCard)
);
}
if (doors.length > 0) {
securitySectionCards.push(
computeHeadingCard("Doors", "mdi:door", "subtitle"),
...doors.map(computeTileCard)
);
}
if (securitySensors.length > 0) {
securitySectionCards.push(
computeHeadingCard("Sensors", "mdi:wifi", "subtitle"),
...securitySensors.map(computeTileCard)
);
}
if (securitySectionCards.length > 0) {
if (security.length > 0) {
sections.push({
type: "grid",
cards: securitySectionCards,
cards: [
computeHeadingCard("Security", "mdi:security"),
...security.map(computeTileCard),
],
});
}
return {
type: "sections",
header: {
badges_position: "bottom",
layout: "responsive",
card: {
type: "markdown",
text_only: true,
content: `## ${area.name}`,
},
},
max_columns: 2,
sections: sections,
badges: badges,

View File

@ -1,34 +1,45 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { areaCompare } from "../../../../data/area_registry";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
import type { AreaViewStrategyConfig } from "../area/area-view-strategy";
import type { LovelaceStrategyEditor } from "../types";
import type { AreasViewStrategyConfig } from "./areas-view-strategy";
import { computeAreaPath, getAreas } from "./helpers/areas-strategy-helpers";
export interface AreasDashboardStrategyConfig {}
export interface AreasDashboardStrategyConfig {
type: "areas";
areas_display?: {
hidden?: string[];
order?: string[];
};
}
@customElement("areas-dashboard-strategy")
export class AreasDashboardStrategy extends ReactiveElement {
static async generate(
_config: AreasDashboardStrategyConfig,
config: AreasDashboardStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceConfig> {
const compare = areaCompare(hass.areas);
const areas = Object.values(hass.areas).sort((a, b) =>
compare(a.area_id, b.area_id)
const areas = getAreas(
hass.areas,
config.areas_display?.hidden,
config.areas_display?.order
);
const areaViews = areas.map<LovelaceViewRawConfig>((area) => ({
title: area.name,
icon: area.icon || undefined,
path: `areas-${area.area_id}`,
subview: true,
strategy: {
type: "area",
area: area.area_id,
} satisfies AreaViewStrategyConfig,
}));
const areaViews = areas.map<LovelaceViewRawConfig>((area) => {
const path = computeAreaPath(area.area_id);
return {
title: area.name,
icon: area.icon || undefined,
path: path,
strategy: {
type: "area",
area: area.area_id,
} satisfies AreaViewStrategyConfig,
};
});
return {
views: [
@ -38,12 +49,18 @@ export class AreasDashboardStrategy extends ReactiveElement {
path: "home",
strategy: {
type: "areas",
},
areas_display: config.areas_display,
} satisfies AreasViewStrategyConfig,
},
...areaViews,
],
};
}
public static async getConfigElement(): Promise<LovelaceStrategyEditor> {
await import("./editor/hui-areas-dashboard-strategy-editor");
return document.createElement("hui-areas-dashboard-strategy-editor");
}
}
declare global {

View File

@ -1,57 +1,81 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { areaCompare } from "../../../../data/area_registry";
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
import { getAreaGroupedEntities } from "../area/area-view-strategy";
import { computeAreaPath, getAreas } from "./helpers/areas-strategy-helpers";
export interface AreasViewStrategyConfig {
type: "areas";
areas_display?: {
hidden?: string[];
order?: string[];
};
}
@customElement("areas-view-strategy")
export class AreasViewStrategy extends ReactiveElement {
static async generate(
_config: AreasViewStrategyConfig,
config: AreasViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const compare = areaCompare(hass.areas);
const areas = Object.values(hass.areas).sort((a, b) =>
compare(a.area_id, b.area_id)
const areas = getAreas(
hass.areas,
config.areas_display?.hidden,
config.areas_display?.order
);
const areaSections = areas.map<LovelaceSectionConfig>((area) => {
const areaPath = `areas-${area.area_id}`;
return {
type: "grid",
cards: [
{
type: "heading",
heading: area.name,
icon: area.icon || undefined,
badges: [
...(area.temperature_entity_id
? [{ entity: area.temperature_entity_id }]
: []),
...(area.humidity_entity_id
? [{ entity: area.humidity_entity_id }]
: []),
],
tap_action: {
action: "navigate",
navigation_path: areaPath,
const areaSections = areas
.map<LovelaceSectionConfig | undefined>((area) => {
const path = computeAreaPath(area.area_id);
const groups = getAreaGroupedEntities(area.area_id, hass, true);
const entities = [
...groups.lights,
...groups.climate,
...groups.media_players,
...groups.security,
];
return {
type: "grid",
cards: [
{
type: "heading",
heading: area.name,
icon: area.icon || undefined,
badges: [
...(area.temperature_entity_id
? [{ entity: area.temperature_entity_id }]
: []),
...(area.humidity_entity_id
? [{ entity: area.humidity_entity_id }]
: []),
],
tap_action: {
action: "navigate",
navigation_path: path,
},
},
},
{
type: "area",
area: area.area_id,
navigation_path: areaPath,
alert_classes: [],
sensor_classes: [],
},
],
};
});
...(entities.length
? entities.map((entity) => ({
type: "tile",
entity: entity,
}))
: [
{
type: "markdown",
content: "No controllable devices in this area.",
},
]),
],
};
})
.filter(
(section): section is LovelaceSectionConfig => section !== undefined
);
return {
type: "sections",

View File

@ -0,0 +1,59 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-areas-display-editor";
import type { AreasDisplayValue } from "../../../../../components/ha-areas-display-editor";
import type { HomeAssistant } from "../../../../../types";
import type { LovelaceStrategyEditor } from "../../types";
import type { AreasDashboardStrategyConfig } from "../areas-dashboard-strategy";
@customElement("hui-areas-dashboard-strategy-editor")
export class HuiAreasDashboardStrategyEditor
extends LitElement
implements LovelaceStrategyEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state()
private _config?: AreasDashboardStrategyConfig;
public setConfig(config: AreasDashboardStrategyConfig): void {
this._config = config;
}
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const value = this._config.areas_display;
return html`
<ha-areas-display-editor
.hass=${this.hass}
.value=${value}
.label=${this.hass.localize(
"ui.panel.lovelace.editor.strategy.areas.areas_display"
)}
@value-changed=${this._areaDisplayChanged}
expanded
></ha-areas-display-editor>
`;
}
private _areaDisplayChanged(ev: CustomEvent): void {
const value = ev.detail.value as AreasDisplayValue;
const newConfig: AreasDashboardStrategyConfig = {
...this._config!,
areas_display: value,
};
fireEvent(this, "config-changed", { config: newConfig });
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-areas-dashboard-strategy-editor": HuiAreasDashboardStrategyEditor;
}
}

View File

@ -0,0 +1,25 @@
import type { AreaRegistryEntry } from "../../../../../data/area_registry";
import { areaCompare } from "../../../../../data/area_registry";
import type { HomeAssistant } from "../../../../../types";
export const getAreas = (
entries: HomeAssistant["areas"],
hiddenAreas?: string[],
areasOrder?: string[]
): AreaRegistryEntry[] => {
const areas = Object.values(entries);
const filteredAreas = hiddenAreas
? areas.filter((area) => !hiddenAreas!.includes(area.area_id))
: areas.concat();
const compare = areaCompare(entries, areasOrder);
const sortedAreas = filteredAreas.sort((areaA, areaB) =>
compare(areaA.area_id, areaB.area_id)
);
return sortedAreas;
};
export const computeAreaPath = (areaId: string): string => `areas-${areaId}`;

View File

@ -2,7 +2,7 @@ import { STATE_NOT_RUNNING } from "home-assistant-js-websocket";
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import type { AreaFilterValue } from "../../../../components/ha-area-filter";
import type { AreasDisplayValue } from "../../../../components/ha-areas-display-editor";
import { getEnergyPreferences } from "../../../../data/energy";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
@ -10,7 +10,7 @@ import { generateDefaultViewConfig } from "../../common/generate-lovelace-config
export interface OriginalStatesViewStrategyConfig {
type: "original-states";
areas?: AreaFilterValue;
areas?: AreasDisplayValue;
hide_entities_without_area?: boolean;
hide_energy?: boolean;
}

View File

@ -1127,6 +1127,10 @@
},
"multi-textfield": {
"add_item": "Add {item}"
},
"items-display-editor": {
"show": "Show {label}",
"hide": "Hide {label}"
}
},
"dialogs": {
@ -3121,6 +3125,10 @@
"iframe": {
"title": "Webpage",
"description": "Integrate a webpage as a dashboard"
},
"areas": {
"title": "Areas (experimental)",
"description": "Display your devices with a view for each area"
}
}
},
@ -7456,12 +7464,15 @@
},
"strategy": {
"original-states": {
"areas": "Areas",
"areas": "Areas to display",
"hide_entities_without_area": "Hide entities without area",
"hide_energy": "Hide energy"
},
"iframe": {
"url": "URL"
},
"areas": {
"areas_display": "Areas to display"
}
},
"view": {