Add foundation for areas dashboard strategy (#24582)

* Add entity filter section strategy

* Rename parameters

* Add group support

* Add empty state

* Add area strategy

* Remove title

* Fix heading

* Add areas dashboard and views

* Use satisfies

* Remove unnecessary array copy

* Only define set if needed

* Sort area by name

* Fix sorting

* Use entity id

* Don't use section strategy in view

* Simplify view

* Remove section related changes
This commit is contained in:
Paul Bottein 2025-03-13 17:03:35 +01:00 committed by GitHub
parent ee10f9080d
commit e09dbb474b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 578 additions and 0 deletions

View File

@ -0,0 +1,43 @@
import type { AreaRegistryEntry } from "../../../data/area_registry";
import type { DeviceRegistryEntry } from "../../../data/device_registry";
import type { EntityRegistryDisplayEntry } from "../../../data/entity_registry";
import type { FloorRegistryEntry } from "../../../data/floor_registry";
import type { HomeAssistant } from "../../../types";
interface EntityContext {
entity: EntityRegistryDisplayEntry | null;
device: DeviceRegistryEntry | null;
area: AreaRegistryEntry | null;
floor: FloorRegistryEntry | null;
}
export const getEntityContext = (
entityId: string,
hass: HomeAssistant
): EntityContext => {
const entity =
(hass.entities[entityId] as EntityRegistryDisplayEntry | undefined) || null;
if (!entity) {
return {
entity: null,
device: null,
area: null,
floor: null,
};
}
const deviceId = entity?.device_id;
const device = deviceId ? hass.devices[deviceId] : null;
const areaId = entity?.area_id || device?.area_id;
const area = areaId ? hass.areas[areaId] : null;
const floorId = area?.floor_id;
const floor = floorId ? hass.floors[floorId] : null;
return {
entity: entity,
device: device,
area: area,
floor: floor,
};
};

View File

@ -0,0 +1,124 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../../types";
import { ensureArray } from "../array/ensure-array";
import { computeDomain } from "./compute_domain";
import { getEntityContext } from "./context/get_entity_context";
type EntityCategory = "none" | "config" | "diagnostic";
export interface EntityFilter {
domain?: string | string[];
device_class?: string | string[];
device?: string | string[];
area?: string | string[];
floor?: string | string[];
label?: string | string[];
entity_category?: EntityCategory | EntityCategory[];
hidden_platform?: string | string[];
}
type EntityFilterFunc = (entityId: string) => boolean;
export const generateEntityFilter = (
hass: HomeAssistant,
filter: EntityFilter
): EntityFilterFunc => {
const domains = filter.domain
? new Set(ensureArray(filter.domain))
: undefined;
const deviceClasses = filter.device_class
? new Set(ensureArray(filter.device_class))
: undefined;
const floors = filter.floor ? new Set(ensureArray(filter.floor)) : undefined;
const areas = filter.area ? new Set(ensureArray(filter.area)) : undefined;
const devices = filter.device
? new Set(ensureArray(filter.device))
: undefined;
const entityCategories = filter.entity_category
? new Set(ensureArray(filter.entity_category))
: undefined;
const labels = filter.label ? new Set(ensureArray(filter.label)) : undefined;
const hiddenPlatforms = filter.hidden_platform
? new Set(ensureArray(filter.hidden_platform))
: undefined;
return (entityId: string) => {
const stateObj = hass.states[entityId] as HassEntity | undefined;
if (!stateObj) {
return false;
}
if (domains) {
const domain = computeDomain(entityId);
if (!domains.has(domain)) {
return false;
}
}
if (deviceClasses) {
const dc = stateObj.attributes.device_class;
if (!dc) {
return false;
}
if (!deviceClasses.has(dc)) {
return false;
}
}
const { area, floor, device, entity } = getEntityContext(entityId, hass);
if (entity && entity.hidden) {
return false;
}
if (floors) {
if (!floor) {
return false;
}
if (!floors) {
return false;
}
}
if (areas) {
if (!area) {
return false;
}
if (!areas.has(area.area_id)) {
return false;
}
}
if (devices) {
if (!device) {
return false;
}
if (!devices.has(device.id)) {
return false;
}
}
if (labels) {
if (!entity) {
return false;
}
if (!entity.labels.some((label) => labels.has(label))) {
return false;
}
}
if (entityCategories) {
if (!entity) {
return false;
}
const category = entity?.entity_category || "none";
if (!entityCategories.has(category)) {
return false;
}
}
if (hiddenPlatforms) {
if (!entity) {
return false;
}
if (entity.platform && hiddenPlatforms.has(entity.platform)) {
return false;
}
}
return true;
};
};

View File

@ -0,0 +1,287 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
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";
export interface AreaViewStrategyConfig {
type: "area";
area?: string;
}
const computeTileCard = (entity: string): LovelaceCardConfig => ({
type: "tile",
entity: entity,
});
const computeHeadingCard = (
heading: string,
icon: string,
style: "title" | "subtitle" = "title"
): LovelaceCardConfig => ({
type: "heading",
heading: heading,
heading_style: style,
icon: icon,
});
@customElement("area-view-strategy")
export class AreaViewStrategy extends ReactiveElement {
static async generate(
config: AreaViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
if (!config.area) {
throw new Error("Area not provided");
}
const area = hass.areas[config.area];
if (!area) {
throw new Error("Unknown area");
}
const sections: LovelaceSectionRawConfig[] = [];
const badges: LovelaceBadgeConfig[] = [];
if (area.temperature_entity_id) {
badges.push({
entity: area.temperature_entity_id,
type: "entity",
color: "red",
});
}
if (area.humidity_entity_id) {
badges.push({
entity: area.humidity_entity_id,
type: "entity",
color: "indigo",
});
}
const allEntities = Object.keys(hass.states);
// Lights
const lights = allEntities.filter(
generateEntityFilter(hass, {
domain: "light",
area: config.area,
entity_category: "none",
})
);
if (lights.length) {
sections.push({
type: "grid",
cards: [
{
type: "heading",
heading: "Lights",
icon: "mdi:lamps",
},
...lights.map((entity) => ({
type: "tile",
entity: entity,
})),
],
});
}
// 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) {
sections.push({
type: "grid",
cards: climateSectionCards,
});
}
// 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",
cards: [
computeHeadingCard("Entertainment", "mdi:multimedia"),
...mediaPlayers.map(computeTileCard),
],
});
}
// 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) {
sections.push({
type: "grid",
cards: securitySectionCards,
});
}
return {
type: "sections",
max_columns: 2,
sections: sections,
badges: badges,
};
}
}
declare global {
interface HTMLElementTagNameMap {
"area-view-strategy": AreaViewStrategy;
}
}

View File

@ -0,0 +1,53 @@
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";
export interface AreasDashboardStrategyConfig {}
@customElement("areas-dashboard-strategy")
export class AreasDashboardStrategy extends ReactiveElement {
static async generate(
_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 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,
}));
return {
views: [
{
title: "Home",
icon: "mdi:home",
path: "home",
strategy: {
type: "areas",
},
},
...areaViews,
],
};
}
}
declare global {
interface HTMLElementTagNameMap {
"areas-dashboard-strategy": AreasDashboardStrategy;
}
}

View File

@ -0,0 +1,68 @@
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";
export interface AreasViewStrategyConfig {
type: "areas";
}
@customElement("areas-view-strategy")
export class AreasViewStrategy extends ReactiveElement {
static async generate(
_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 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,
},
},
{
type: "area",
area: area.area_id,
navigation_path: areaPath,
alert_classes: [],
sensor_classes: [],
},
],
};
});
return {
type: "sections",
max_columns: 3,
sections: areaSections,
};
}
}
declare global {
interface HTMLElementTagNameMap {
"areas-view-strategy": AreasViewStrategy;
}
}

View File

@ -24,6 +24,7 @@ const STRATEGIES: Record<LovelaceStrategyConfigType, Record<string, any>> = {
import("./original-states/original-states-dashboard-strategy"),
map: () => import("./map/map-dashboard-strategy"),
iframe: () => import("./iframe/iframe-dashboard-strategy"),
areas: () => import("./areas/areas-dashboard-strategy"),
},
view: {
"original-states": () =>
@ -31,6 +32,8 @@ const STRATEGIES: Record<LovelaceStrategyConfigType, Record<string, any>> = {
energy: () => import("../../energy/strategies/energy-view-strategy"),
map: () => import("./map/map-view-strategy"),
iframe: () => import("./iframe/iframe-view-strategy"),
area: () => import("./area/area-view-strategy"),
areas: () => import("./areas/areas-view-strategy"),
},
section: {},
};