From e09dbb474b9ae8c541631fa80cc09f7462468f37 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 13 Mar 2025 17:03:35 +0100 Subject: [PATCH] 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 --- .../entity/context/get_entity_context.ts | 43 +++ src/common/entity/entity_filter.ts | 124 ++++++++ .../strategies/area/area-view-strategy.ts | 287 ++++++++++++++++++ .../areas/areas-dashboard-strategy.ts | 53 ++++ .../strategies/areas/areas-view-strategy.ts | 68 +++++ .../lovelace/strategies/get-strategy.ts | 3 + 6 files changed, 578 insertions(+) create mode 100644 src/common/entity/context/get_entity_context.ts create mode 100644 src/common/entity/entity_filter.ts create mode 100644 src/panels/lovelace/strategies/area/area-view-strategy.ts create mode 100644 src/panels/lovelace/strategies/areas/areas-dashboard-strategy.ts create mode 100644 src/panels/lovelace/strategies/areas/areas-view-strategy.ts diff --git a/src/common/entity/context/get_entity_context.ts b/src/common/entity/context/get_entity_context.ts new file mode 100644 index 0000000000..1ad2aa8312 --- /dev/null +++ b/src/common/entity/context/get_entity_context.ts @@ -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, + }; +}; diff --git a/src/common/entity/entity_filter.ts b/src/common/entity/entity_filter.ts new file mode 100644 index 0000000000..53558e5298 --- /dev/null +++ b/src/common/entity/entity_filter.ts @@ -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; + }; +}; diff --git a/src/panels/lovelace/strategies/area/area-view-strategy.ts b/src/panels/lovelace/strategies/area/area-view-strategy.ts new file mode 100644 index 0000000000..53716164e8 --- /dev/null +++ b/src/panels/lovelace/strategies/area/area-view-strategy.ts @@ -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 { + 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; + } +} diff --git a/src/panels/lovelace/strategies/areas/areas-dashboard-strategy.ts b/src/panels/lovelace/strategies/areas/areas-dashboard-strategy.ts new file mode 100644 index 0000000000..6de6bc7704 --- /dev/null +++ b/src/panels/lovelace/strategies/areas/areas-dashboard-strategy.ts @@ -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 { + 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((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; + } +} diff --git a/src/panels/lovelace/strategies/areas/areas-view-strategy.ts b/src/panels/lovelace/strategies/areas/areas-view-strategy.ts new file mode 100644 index 0000000000..502aebf4ac --- /dev/null +++ b/src/panels/lovelace/strategies/areas/areas-view-strategy.ts @@ -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 { + 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((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; + } +} diff --git a/src/panels/lovelace/strategies/get-strategy.ts b/src/panels/lovelace/strategies/get-strategy.ts index 5d7cafe896..f39b2b9302 100644 --- a/src/panels/lovelace/strategies/get-strategy.ts +++ b/src/panels/lovelace/strategies/get-strategy.ts @@ -24,6 +24,7 @@ const STRATEGIES: Record> = { 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> = { 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: {}, };