mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-18 23:06:40 +00:00
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:
parent
ee10f9080d
commit
e09dbb474b
43
src/common/entity/context/get_entity_context.ts
Normal file
43
src/common/entity/context/get_entity_context.ts
Normal 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,
|
||||
};
|
||||
};
|
124
src/common/entity/entity_filter.ts
Normal file
124
src/common/entity/entity_filter.ts
Normal 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;
|
||||
};
|
||||
};
|
287
src/panels/lovelace/strategies/area/area-view-strategy.ts
Normal file
287
src/panels/lovelace/strategies/area/area-view-strategy.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
68
src/panels/lovelace/strategies/areas/areas-view-strategy.ts
Normal file
68
src/panels/lovelace/strategies/areas/areas-view-strategy.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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: {},
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user