From d59d4360808f9e786fc3257bac255f267a0bbc63 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 4 Nov 2025 11:10:18 +0100 Subject: [PATCH] Display entities without area in summary dashboard (#27777) * Add support for no area, no floor and no device in entity filter * Display entities without area in summary dashboard --- src/common/entity/entity_filter.ts | 41 +++++++----- .../strategies/climate-view-strategy.ts | 43 +++++++++++- .../light/strategies/light-view-strategy.ts | 40 ++++++++++- .../lovelace/cards/hui-home-summary-card.ts | 11 +--- .../home/home-media-players-view-strategy.ts | 47 ++++++++++++- .../safety/strategies/safety-view-strategy.ts | 43 +++++++++++- src/translations/en.json | 16 +++++ test/common/entity/entity_filter.test.ts | 66 +++++++++++++++++++ 8 files changed, 278 insertions(+), 29 deletions(-) diff --git a/src/common/entity/entity_filter.ts b/src/common/entity/entity_filter.ts index 7a8174f388..7382a5e229 100644 --- a/src/common/entity/entity_filter.ts +++ b/src/common/entity/entity_filter.ts @@ -9,9 +9,9 @@ type EntityCategory = "none" | "config" | "diagnostic"; export interface EntityFilter { domain?: string | string[]; device_class?: string | string[]; - device?: string | string[]; - area?: string | string[]; - floor?: string | string[]; + device?: string | null | (string | null)[]; + area?: string | null | (string | null)[]; + floor?: string | null | (string | null)[]; label?: string | string[]; entity_category?: EntityCategory | EntityCategory[]; hidden_platform?: string | string[]; @@ -19,6 +19,18 @@ export interface EntityFilter { export type EntityFilterFunc = (entityId: string) => boolean; +const normalizeFilterArray = ( + value: T | null | T[] | (T | null)[] | undefined +): Set | undefined => { + if (value === undefined) { + return undefined; + } + if (value === null) { + return new Set([null]); + } + return new Set(ensureArray(value)); +}; + export const generateEntityFilter = ( hass: HomeAssistant, filter: EntityFilter @@ -29,11 +41,9 @@ export const generateEntityFilter = ( 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 floors = normalizeFilterArray(filter.floor); + const areas = normalizeFilterArray(filter.area); + const devices = normalizeFilterArray(filter.device); const entityCategories = filter.entity_category ? new Set(ensureArray(filter.entity_category)) : undefined; @@ -73,23 +83,20 @@ export const generateEntityFilter = ( } if (floors) { - if (!floor || !floors.has(floor.floor_id)) { + const floorId = floor?.floor_id ?? null; + if (!floors.has(floorId)) { return false; } } if (areas) { - if (!area) { - return false; - } - if (!areas.has(area.area_id)) { + const areaId = area?.area_id ?? null; + if (!areas.has(areaId)) { return false; } } if (devices) { - if (!device) { - return false; - } - if (!devices.has(device.id)) { + const deviceId = device?.id ?? null; + if (!devices.has(deviceId)) { return false; } } diff --git a/src/panels/climate/strategies/climate-view-strategy.ts b/src/panels/climate/strategies/climate-view-strategy.ts index 0b22087419..26abc9a3bf 100644 --- a/src/panels/climate/strategies/climate-view-strategy.ts +++ b/src/panels/climate/strategies/climate-view-strategy.ts @@ -115,6 +115,24 @@ const processAreasForClimate = ( return cards; }; +const processUnassignedEntities = ( + hass: HomeAssistant, + entities: string[] +): LovelaceCardConfig[] => { + const unassignedFilter = generateEntityFilter(hass, { + area: null, + }); + const unassignedEntities = entities.filter(unassignedFilter); + const areaCards: LovelaceCardConfig[] = []; + const computeTileCard = computeAreaTileCardConfig(hass, "", true); + + for (const entityId of unassignedEntities) { + areaCards.push(computeTileCard(entityId)); + } + + return areaCards; +}; + @customElement("climate-view-strategy") export class ClimateViewStrategy extends ReactiveElement { static async generate( @@ -190,10 +208,33 @@ export class ClimateViewStrategy extends ReactiveElement { } } + // Process unassigned entities + const unassignedCards = processUnassignedEntities(hass, entities); + + if (unassignedCards.length > 0) { + const section: LovelaceSectionRawConfig = { + type: "grid", + column_span: 2, + cards: [ + { + type: "heading", + heading: + sections.length > 0 + ? hass.localize( + "ui.panel.lovelace.strategy.climate.other_devices" + ) + : hass.localize("ui.panel.lovelace.strategy.climate.devices"), + }, + ...unassignedCards, + ], + }; + sections.push(section); + } + return { type: "sections", max_columns: 2, - sections: sections || [], + sections: sections, }; } } diff --git a/src/panels/light/strategies/light-view-strategy.ts b/src/panels/light/strategies/light-view-strategy.ts index 9bed1dfbbf..b3de88f1cf 100644 --- a/src/panels/light/strategies/light-view-strategy.ts +++ b/src/panels/light/strategies/light-view-strategy.ts @@ -61,6 +61,24 @@ const processAreasForLight = ( return cards; }; +const processUnassignedLights = ( + hass: HomeAssistant, + entities: string[] +): LovelaceCardConfig[] => { + const unassignedFilter = generateEntityFilter(hass, { + area: null, + }); + const unassignedLights = entities.filter(unassignedFilter); + const areaCards: LovelaceCardConfig[] = []; + const computeTileCard = computeAreaTileCardConfig(hass, "", false); + + for (const entityId of unassignedLights) { + areaCards.push(computeTileCard(entityId)); + } + + return areaCards; +}; + @customElement("light-view-strategy") export class LightViewStrategy extends ReactiveElement { static async generate( @@ -136,10 +154,30 @@ export class LightViewStrategy extends ReactiveElement { } } + // Process unassigned lights + const unassignedCards = processUnassignedLights(hass, entities); + if (unassignedCards.length > 0) { + const section: LovelaceSectionRawConfig = { + type: "grid", + column_span: 2, + cards: [ + { + type: "heading", + heading: + sections.length > 0 + ? hass.localize("ui.panel.lovelace.strategy.light.other_lights") + : hass.localize("ui.panel.lovelace.strategy.light.lights"), + }, + ...unassignedCards, + ], + }; + sections.push(section); + } + return { type: "sections", max_columns: 2, - sections: sections || [], + sections: sections, }; } } diff --git a/src/panels/lovelace/cards/hui-home-summary-card.ts b/src/panels/lovelace/cards/hui-home-summary-card.ts index ff9f4d1e22..709ea77f3b 100644 --- a/src/panels/lovelace/cards/hui-home-summary-card.ts +++ b/src/panels/lovelace/cards/hui-home-summary-card.ts @@ -87,11 +87,6 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard { const allEntities = Object.keys(this.hass!.states); const areas = Object.values(this.hass.areas); - const areasFilter = generateEntityFilter(this.hass, { - area: areas.map((area) => area.area_id), - }); - - const entitiesInsideArea = allEntities.filter(areasFilter); switch (this._config.summary) { case "light": { @@ -100,7 +95,7 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard { generateEntityFilter(this.hass!, filter) ); - const lightEntities = findEntities(entitiesInsideArea, lightsFilters); + const lightEntities = findEntities(allEntities, lightsFilters); const onLights = lightEntities.filter((entityId) => { const s = this.hass!.states[entityId]?.state; @@ -153,7 +148,7 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard { generateEntityFilter(this.hass!, filter) ); - const safetyEntities = findEntities(entitiesInsideArea, safetyFilters); + const safetyEntities = findEntities(allEntities, safetyFilters); const locks = safetyEntities.filter((entityId) => { const domain = computeDomain(entityId); @@ -204,7 +199,7 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard { ); const mediaPlayerEntities = findEntities( - entitiesInsideArea, + allEntities, mediaPlayerFilters ); diff --git a/src/panels/lovelace/strategies/home/home-media-players-view-strategy.ts b/src/panels/lovelace/strategies/home/home-media-players-view-strategy.ts index 4a099166ec..04573e79bb 100644 --- a/src/panels/lovelace/strategies/home/home-media-players-view-strategy.ts +++ b/src/panels/lovelace/strategies/home/home-media-players-view-strategy.ts @@ -59,6 +59,26 @@ const processAreasForMediaPlayers = ( return cards; }; +const processUnassignedEntities = ( + hass: HomeAssistant, + entities: string[] +): LovelaceCardConfig[] => { + const unassignedFilter = generateEntityFilter(hass, { + area: null, + }); + const unassignedEntities = entities.filter(unassignedFilter); + const areaCards: LovelaceCardConfig[] = []; + + for (const entityId of unassignedEntities) { + areaCards.push({ + type: "media-control", + entity: entityId, + } satisfies MediaControlCardConfig); + } + + return areaCards; +}; + @customElement("home-media-players-view-strategy") export class HomeMMediaPlayersViewStrategy extends ReactiveElement { static async generate( @@ -134,10 +154,35 @@ export class HomeMMediaPlayersViewStrategy extends ReactiveElement { } } + // Process unassigned entities + const unassignedCards = processUnassignedEntities(hass, entities); + + if (unassignedCards.length > 0) { + const section: LovelaceSectionRawConfig = { + type: "grid", + column_span: 2, + cards: [ + { + type: "heading", + heading: + sections.length > 0 + ? hass.localize( + "ui.panel.lovelace.strategy.home_media_players.other_media_players" + ) + : hass.localize( + "ui.panel.lovelace.strategy.home_media_players.media_players" + ), + }, + ...unassignedCards, + ], + }; + sections.push(section); + } + return { type: "sections", max_columns: 2, - sections: sections || [], + sections: sections, }; } } diff --git a/src/panels/safety/strategies/safety-view-strategy.ts b/src/panels/safety/strategies/safety-view-strategy.ts index 5e04ab9d23..829c0c93ea 100644 --- a/src/panels/safety/strategies/safety-view-strategy.ts +++ b/src/panels/safety/strategies/safety-view-strategy.ts @@ -103,6 +103,24 @@ const processAreasForSafety = ( return cards; }; +const processUnassignedEntities = ( + hass: HomeAssistant, + entities: string[] +): LovelaceCardConfig[] => { + const unassignedFilter = generateEntityFilter(hass, { + area: null, + }); + const unassignedLights = entities.filter(unassignedFilter); + const areaCards: LovelaceCardConfig[] = []; + const computeTileCard = computeAreaTileCardConfig(hass, "", false); + + for (const entityId of unassignedLights) { + areaCards.push(computeTileCard(entityId)); + } + + return areaCards; +}; + @customElement("safety-view-strategy") export class SafetyViewStrategy extends ReactiveElement { static async generate( @@ -178,10 +196,33 @@ export class SafetyViewStrategy extends ReactiveElement { } } + // Process unassigned entities + const unassignedCards = processUnassignedEntities(hass, entities); + + if (unassignedCards.length > 0) { + const section: LovelaceSectionRawConfig = { + type: "grid", + column_span: 2, + cards: [ + { + type: "heading", + heading: + sections.length > 0 + ? hass.localize( + "ui.panel.lovelace.strategy.safety.other_devices" + ) + : hass.localize("ui.panel.lovelace.strategy.safety.devices"), + }, + ...unassignedCards, + ], + }; + sections.push(section); + } + return { type: "sections", max_columns: 2, - sections: sections || [], + sections: sections, }; } } diff --git a/src/translations/en.json b/src/translations/en.json index 1a35e26eb6..56fff0b2fe 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -6975,6 +6975,22 @@ "common_controls": { "not_loaded": "Usage Prediction integration is not loaded.", "no_data": "This place will soon fill up with the entities you use most often, based on your activity." + }, + "light": { + "lights": "Lights", + "other_lights": "Other lights" + }, + "safety": { + "devices": "Devices", + "other_devices": "Other devices" + }, + "climate": { + "devices": "Devices", + "other_devices": "Other devices" + }, + "home_media_players": { + "media_players": "Media players", + "other_media_players": "Other media players" } }, "cards": { diff --git a/test/common/entity/entity_filter.test.ts b/test/common/entity/entity_filter.test.ts index 032eadd32f..eba5346555 100644 --- a/test/common/entity/entity_filter.test.ts +++ b/test/common/entity/entity_filter.test.ts @@ -388,4 +388,70 @@ describe("generateEntityFilter", () => { expect(filter("light.no_area")).toBe(false); }); }); + + describe("null filtering", () => { + it("should filter entities with no area when null is used", () => { + const filter = generateEntityFilter(mockHass, { area: null }); + + expect(filter("light.no_area")).toBe(true); + expect(filter("light.living_room")).toBe(false); + }); + + it("should filter entities with specific area OR no area when null is in array", () => { + const filter = generateEntityFilter(mockHass, { + area: ["living_room", null], + }); + + expect(filter("light.living_room")).toBe(true); + expect(filter("sensor.temperature")).toBe(true); + expect(filter("light.no_area")).toBe(true); + expect(filter("switch.kitchen")).toBe(false); + }); + + it("should filter entities with no floor when null is used", () => { + const filter = generateEntityFilter(mockHass, { floor: null }); + + expect(filter("light.no_area")).toBe(true); + expect(filter("light.living_room")).toBe(false); + }); + + it("should filter entities with specific floor OR no floor", () => { + const filter = generateEntityFilter(mockHass, { + floor: ["main_floor", null], + }); + + expect(filter("light.living_room")).toBe(true); + expect(filter("switch.kitchen")).toBe(true); + expect(filter("light.no_area")).toBe(true); + expect(filter("light.bedroom")).toBe(false); + }); + + it("should filter entities with no device when null is used", () => { + const filter = generateEntityFilter(mockHass, { device: null }); + + expect(filter("light.living_room")).toBe(false); + expect(filter("light.no_area")).toBe(false); + }); + + it("should filter entities with specific device OR no device", () => { + const filter = generateEntityFilter(mockHass, { + device: ["device1", null], + }); + + expect(filter("light.living_room")).toBe(true); + expect(filter("switch.kitchen")).toBe(false); + }); + + it("should combine null filtering with other criteria", () => { + const filter = generateEntityFilter(mockHass, { + domain: "light", + area: ["living_room", null], + }); + + expect(filter("light.living_room")).toBe(true); + expect(filter("light.no_area")).toBe(true); + expect(filter("light.bedroom")).toBe(false); + expect(filter("sensor.temperature")).toBe(false); + }); + }); });