1
0
mirror of https://github.com/home-assistant/frontend.git synced 2025-12-24 12:49:19 +00:00

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
This commit is contained in:
Paul Bottein
2025-11-04 11:10:18 +01:00
committed by GitHub
parent 0fe0bf12f2
commit d59d436080
8 changed files with 278 additions and 29 deletions

View File

@@ -9,9 +9,9 @@ type EntityCategory = "none" | "config" | "diagnostic";
export interface EntityFilter { export interface EntityFilter {
domain?: string | string[]; domain?: string | string[];
device_class?: string | string[]; device_class?: string | string[];
device?: string | string[]; device?: string | null | (string | null)[];
area?: string | string[]; area?: string | null | (string | null)[];
floor?: string | string[]; floor?: string | null | (string | null)[];
label?: string | string[]; label?: string | string[];
entity_category?: EntityCategory | EntityCategory[]; entity_category?: EntityCategory | EntityCategory[];
hidden_platform?: string | string[]; hidden_platform?: string | string[];
@@ -19,6 +19,18 @@ export interface EntityFilter {
export type EntityFilterFunc = (entityId: string) => boolean; export type EntityFilterFunc = (entityId: string) => boolean;
const normalizeFilterArray = <T>(
value: T | null | T[] | (T | null)[] | undefined
): Set<T | null> | undefined => {
if (value === undefined) {
return undefined;
}
if (value === null) {
return new Set([null]);
}
return new Set(ensureArray(value));
};
export const generateEntityFilter = ( export const generateEntityFilter = (
hass: HomeAssistant, hass: HomeAssistant,
filter: EntityFilter filter: EntityFilter
@@ -29,11 +41,9 @@ export const generateEntityFilter = (
const deviceClasses = filter.device_class const deviceClasses = filter.device_class
? new Set(ensureArray(filter.device_class)) ? new Set(ensureArray(filter.device_class))
: undefined; : undefined;
const floors = filter.floor ? new Set(ensureArray(filter.floor)) : undefined; const floors = normalizeFilterArray(filter.floor);
const areas = filter.area ? new Set(ensureArray(filter.area)) : undefined; const areas = normalizeFilterArray(filter.area);
const devices = filter.device const devices = normalizeFilterArray(filter.device);
? new Set(ensureArray(filter.device))
: undefined;
const entityCategories = filter.entity_category const entityCategories = filter.entity_category
? new Set(ensureArray(filter.entity_category)) ? new Set(ensureArray(filter.entity_category))
: undefined; : undefined;
@@ -73,23 +83,20 @@ export const generateEntityFilter = (
} }
if (floors) { if (floors) {
if (!floor || !floors.has(floor.floor_id)) { const floorId = floor?.floor_id ?? null;
if (!floors.has(floorId)) {
return false; return false;
} }
} }
if (areas) { if (areas) {
if (!area) { const areaId = area?.area_id ?? null;
return false; if (!areas.has(areaId)) {
}
if (!areas.has(area.area_id)) {
return false; return false;
} }
} }
if (devices) { if (devices) {
if (!device) { const deviceId = device?.id ?? null;
return false; if (!devices.has(deviceId)) {
}
if (!devices.has(device.id)) {
return false; return false;
} }
} }

View File

@@ -115,6 +115,24 @@ const processAreasForClimate = (
return cards; 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") @customElement("climate-view-strategy")
export class ClimateViewStrategy extends ReactiveElement { export class ClimateViewStrategy extends ReactiveElement {
static async generate( 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 { return {
type: "sections", type: "sections",
max_columns: 2, max_columns: 2,
sections: sections || [], sections: sections,
}; };
} }
} }

View File

@@ -61,6 +61,24 @@ const processAreasForLight = (
return cards; 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") @customElement("light-view-strategy")
export class LightViewStrategy extends ReactiveElement { export class LightViewStrategy extends ReactiveElement {
static async generate( 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 { return {
type: "sections", type: "sections",
max_columns: 2, max_columns: 2,
sections: sections || [], sections: sections,
}; };
} }
} }

View File

@@ -87,11 +87,6 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
const allEntities = Object.keys(this.hass!.states); const allEntities = Object.keys(this.hass!.states);
const areas = Object.values(this.hass.areas); 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) { switch (this._config.summary) {
case "light": { case "light": {
@@ -100,7 +95,7 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
generateEntityFilter(this.hass!, filter) generateEntityFilter(this.hass!, filter)
); );
const lightEntities = findEntities(entitiesInsideArea, lightsFilters); const lightEntities = findEntities(allEntities, lightsFilters);
const onLights = lightEntities.filter((entityId) => { const onLights = lightEntities.filter((entityId) => {
const s = this.hass!.states[entityId]?.state; const s = this.hass!.states[entityId]?.state;
@@ -153,7 +148,7 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
generateEntityFilter(this.hass!, filter) generateEntityFilter(this.hass!, filter)
); );
const safetyEntities = findEntities(entitiesInsideArea, safetyFilters); const safetyEntities = findEntities(allEntities, safetyFilters);
const locks = safetyEntities.filter((entityId) => { const locks = safetyEntities.filter((entityId) => {
const domain = computeDomain(entityId); const domain = computeDomain(entityId);
@@ -204,7 +199,7 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
); );
const mediaPlayerEntities = findEntities( const mediaPlayerEntities = findEntities(
entitiesInsideArea, allEntities,
mediaPlayerFilters mediaPlayerFilters
); );

View File

@@ -59,6 +59,26 @@ const processAreasForMediaPlayers = (
return cards; 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") @customElement("home-media-players-view-strategy")
export class HomeMMediaPlayersViewStrategy extends ReactiveElement { export class HomeMMediaPlayersViewStrategy extends ReactiveElement {
static async generate( 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 { return {
type: "sections", type: "sections",
max_columns: 2, max_columns: 2,
sections: sections || [], sections: sections,
}; };
} }
} }

View File

@@ -103,6 +103,24 @@ const processAreasForSafety = (
return cards; 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") @customElement("safety-view-strategy")
export class SafetyViewStrategy extends ReactiveElement { export class SafetyViewStrategy extends ReactiveElement {
static async generate( 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 { return {
type: "sections", type: "sections",
max_columns: 2, max_columns: 2,
sections: sections || [], sections: sections,
}; };
} }
} }

View File

@@ -6975,6 +6975,22 @@
"common_controls": { "common_controls": {
"not_loaded": "Usage Prediction integration is not loaded.", "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." "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": { "cards": {

View File

@@ -388,4 +388,70 @@ describe("generateEntityFilter", () => {
expect(filter("light.no_area")).toBe(false); 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);
});
});
}); });