Compare commits

...
Author SHA1 Message Date
Paul Bottein 49c22a97a1 Add device section strategy 2026-10-14 19:36:29 +02:00
7 changed files with 515 additions and 49 deletions

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
import { ASSIST_ENTITIES, SENSOR_ENTITIES } from "../../common/const";
import { computeDomain } from "../../common/entity/compute_domain";
import type { EntityCategory } from "../entity/entity_registry";
export const DEVICE_ENTITY_CATEGORIES = [
"control",
"sensor",
"notify",
"event",
"assist",
"config",
"diagnostic",
] as const;
export type DeviceEntityCategory = (typeof DEVICE_ENTITY_CATEGORIES)[number];
export const computeDeviceEntityCategory = (entry: {
entity_id: string;
entity_category?: EntityCategory | null;
}): DeviceEntityCategory => {
const domain = computeDomain(entry.entity_id);
if (ASSIST_ENTITIES.includes(domain)) {
return "assist";
}
if (domain === "event" || domain === "notify") {
return domain;
}
if (entry.entity_category) {
return entry.entity_category;
}
if (SENSOR_ENTITIES.includes(domain)) {
return "sensor";
}
return "control";
};
+1 -1
View File
@@ -10,7 +10,7 @@ import type { LightColor } from "../light";
import type { NextNamePart, RegistryEntry } from "../registry";
import type { Segment } from "../vacuum";
type EntityCategory = "config" | "diagnostic";
export type EntityCategory = "config" | "diagnostic";
export interface EntityRegistryDisplayEntry {
entity_id: string;
@@ -25,7 +25,6 @@ import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { consume } from "../../../common/decorators/consume";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { ASSIST_ENTITIES, SENSOR_ENTITIES } from "../../../common/const";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain";
@@ -65,6 +64,11 @@ import {
sortConfigEntries,
} from "../../../data/config_entries";
import { fireRelatedContext, fullEntitiesContext } from "../../../data/context";
import {
DEVICE_ENTITY_CATEGORIES,
computeDeviceEntityCategory,
type DeviceEntityCategory,
} from "../../../data/device/device_entity_category";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import {
removeDeviceFromRegistry,
@@ -305,44 +309,11 @@ export class HaConfigDevicePage extends LitElement {
private _entitiesByCategory = memoizeOne(
(entities: EntityRegistryEntry[]) => {
const result = groupBy(entities, (entry) => {
const domain = computeDomain(entry.entity_id);
if (ASSIST_ENTITIES.includes(domain)) {
return "assist";
}
if (domain === "event" || domain === "notify") {
return domain;
}
if (entry.entity_category) {
return entry.entity_category;
}
if (SENSOR_ENTITIES.includes(domain)) {
return "sensor";
}
return "control";
}) as Record<
| "control"
| "event"
| "sensor"
| "assist"
| "notify"
| NonNullable<EntityRegistryEntry["entity_category"]>,
const result = groupBy(entities, computeDeviceEntityCategory) as Record<
DeviceEntityCategory,
EntityRegistryStateEntry[]
>;
for (const key of [
"assist",
"config",
"control",
"diagnostic",
"event",
"notify",
"sensor",
]) {
for (const key of DEVICE_ENTITY_CATEGORIES) {
if (!(key in result)) {
result[key] = [];
}
@@ -909,17 +880,7 @@ export class HaConfigDevicePage extends LitElement {
`;
const entitiesColumn = html`
${(
[
"control",
"sensor",
"notify",
"event",
"assist",
"config",
"diagnostic",
] as const
).map((category) =>
${DEVICE_ENTITY_CATEGORIES.map((category) =>
// Make sure we render controls if no other cards will be rendered
entitiesByCategory[category].length > 0 ||
(entities.length === 0 && category === "control")
@@ -0,0 +1,244 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { STRINGS_SEPARATOR_DOT } from "../../../../common/const";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
import { computeEntityEntryName } from "../../../../common/entity/compute_entity_name_display";
import { stringCompare } from "../../../../common/string/compare";
import {
computeDeviceEntityCategory,
type DeviceEntityCategory,
} from "../../../../data/device/device_entity_category";
import type { DeviceRegistryEntry } from "../../../../data/device/device_registry";
import { findBatteryEntity } from "../../../../data/entity/entity_registry";
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import type { HomeAssistant } from "../../../../types";
import type {
EntitiesCardConfig,
HeadingCardConfig,
MarkdownCardConfig,
TileCardConfig,
} from "../../cards/types";
import type { EntityConfig, SectionConfig } from "../../entity-rows/types";
import type {
LovelaceStrategyDependency,
LovelaceStrategyEditor,
} from "../types";
export const DEVICE_SECTION_CATEGORIES = [
"control",
"sensor",
"event",
"config",
"diagnostic",
] as const satisfies readonly DeviceEntityCategory[];
export type DeviceSectionCategory = (typeof DEVICE_SECTION_CATEGORIES)[number];
export const DEFAULT_DEVICE_SECTION_CATEGORIES: DeviceSectionCategory[] = [
"control",
"sensor",
];
export const DEVICE_SECTION_HEADINGS = ["none", "device", "area"] as const;
export type DeviceSectionHeading = (typeof DEVICE_SECTION_HEADINGS)[number];
export interface DeviceSectionStrategyConfig {
type: "device";
device_id?: string;
categories?: DeviceSectionCategory[];
display?: "tiles" | "list";
group_by_category?: boolean;
heading?: DeviceSectionHeading;
exclude_entities?: string[];
}
const computeTileConfig = (entityId: string): TileCardConfig => ({
type: "tile",
entity: entityId,
name: { type: "entity" },
});
const computeRowConfig = (entityId: string): EntityConfig => ({
entity: entityId,
name: { type: "entity" },
});
const computeDeviceSectionHeading = (
device: DeviceRegistryEntry,
heading: Exclude<DeviceSectionHeading, "none">,
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
localize: HomeAssistant["localize"]
): string => {
const area =
heading === "area" && device.area_id ? areas[device.area_id] : undefined;
const parentDevice =
device.next_name_part === "parent_device" && device.parent_device_id
? devices[device.parent_device_id]
: undefined;
const parts = [
area ? computeAreaName(area) : undefined,
parentDevice ? computeDeviceName(parentDevice) : undefined,
computeDeviceName(device) ||
localize("ui.components.device-picker.unnamed_device"),
].filter(Boolean);
return parts.join(STRINGS_SEPARATOR_DOT);
};
@customElement("device-section-strategy")
export class DeviceSectionStrategy extends ReactiveElement {
static registryDependencies: readonly LovelaceStrategyDependency[] = [
"entities",
"devices",
"areas",
];
public static async getConfigElement(): Promise<LovelaceStrategyEditor> {
await import("./hui-device-section-strategy-editor");
return document.createElement("hui-device-section-strategy-editor");
}
static async generate(
config: DeviceSectionStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceSectionConfig> {
const section: LovelaceSectionConfig = {
type: "grid",
cards: [],
};
const device = config.device_id
? hass.devices[config.device_id]
: undefined;
if (!device) {
section.cards!.push({
type: "markdown",
content: hass.localize(
config.device_id
? "ui.panel.lovelace.strategy.device.device_not_found"
: "ui.panel.lovelace.strategy.device.no_device"
),
} satisfies MarkdownCardConfig);
return section;
}
const heading = config.heading ?? "device";
const categories: readonly DeviceEntityCategory[] =
config.categories ?? DEFAULT_DEVICE_SECTION_CATEGORIES;
const entries = Object.values(hass.entities)
.filter(
(entry) =>
entry.device_id === device.id &&
!entry.hidden &&
entry.entity_id in hass.states &&
!config.exclude_entities?.includes(entry.entity_id)
)
.sort((entry1, entry2) => {
const name1 = computeEntityEntryName(entry1);
const name2 = computeEntityEntryName(entry2);
if (!name1 || !name2) {
return Number(!!name1) - Number(!!name2);
}
return stringCompare(name1, name2, hass.locale.language);
});
const batteryEntry =
heading !== "none" ? findBatteryEntity(hass.states, entries) : undefined;
if (heading !== "none") {
section.cards!.push({
type: "heading",
heading: computeDeviceSectionHeading(
device,
heading,
hass.devices,
hass.areas,
hass.localize
),
tap_action: hass.user?.is_admin
? {
action: "navigate",
navigation_path: `/config/devices/device/${device.id}`,
}
: { action: "none" },
badges: batteryEntry
? [
{
type: "entity",
entity: batteryEntry.entity_id,
tap_action: { action: "more-info" },
},
]
: [],
} satisfies HeadingCardConfig);
}
const entitiesByCategory = new Map<DeviceEntityCategory, string[]>();
for (const entry of entries) {
if (entry === batteryEntry) continue;
const category = computeDeviceEntityCategory(entry);
if (!categories.includes(category)) continue;
if (!entitiesByCategory.has(category)) {
entitiesByCategory.set(category, []);
}
entitiesByCategory.get(category)!.push(entry.entity_id);
}
const shownCategories = DEVICE_SECTION_CATEGORIES.filter((category) =>
entitiesByCategory.has(category)
);
const showCategoryNames =
config.group_by_category && shownCategories.length > 1;
const computeCategoryName = (category: DeviceSectionCategory) =>
hass.localize(`ui.panel.lovelace.strategy.device.categories.${category}`);
if (config.display === "list") {
if (shownCategories.length === 0) {
return section;
}
section.cards!.push({
type: "entities",
entities: shownCategories.flatMap((category) => [
...(showCategoryNames
? [
{
type: "section",
label: computeCategoryName(category),
} satisfies SectionConfig,
]
: []),
...entitiesByCategory.get(category)!.map(computeRowConfig),
]),
} satisfies EntitiesCardConfig);
return section;
}
for (const category of shownCategories) {
if (showCategoryNames) {
section.cards!.push({
type: "heading",
heading_style: "subtitle",
heading: computeCategoryName(category),
} satisfies HeadingCardConfig);
}
section.cards!.push(
...entitiesByCategory.get(category)!.map(computeTileConfig)
);
}
return section;
}
}
declare global {
interface HTMLElementTagNameMap {
"device-section-strategy": DeviceSectionStrategy;
}
}
@@ -0,0 +1,186 @@
import { mdiFormatListBulleted } from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { LovelaceStrategyEditor } from "../types";
import {
DEFAULT_DEVICE_SECTION_CATEGORIES,
DEVICE_SECTION_CATEGORIES,
DEVICE_SECTION_HEADINGS,
type DeviceSectionStrategyConfig,
} from "./device-section-strategy";
@customElement("hui-device-section-strategy-editor")
export class HuiDeviceSectionStrategyEditor
extends LitElement
implements LovelaceStrategyEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@state() private _config?: DeviceSectionStrategyConfig;
public setConfig(config: DeviceSectionStrategyConfig): void {
this._config = config;
}
private _deviceEntities = memoizeOne(
(deviceId: string | undefined, entities: HomeAssistant["entities"]) =>
deviceId
? Object.values(entities)
.filter((entry) => entry.device_id === deviceId && !entry.hidden)
.map((entry) => entry.entity_id)
: []
);
private _schema = memoizeOne(
(localize: LocalizeFunc, deviceEntities: string[]) =>
[
{
name: "device_id",
required: true,
selector: { device: {} },
},
{
name: "heading",
selector: {
select: {
mode: "dropdown",
options: DEVICE_SECTION_HEADINGS.map((heading) => ({
value: heading,
label: localize(
`ui.panel.lovelace.editor.strategy.device.heading_options.${heading}`
),
})),
},
},
},
{
name: "categories",
selector: {
select: {
multiple: true,
mode: "list",
options: DEVICE_SECTION_CATEGORIES.map((category) => ({
value: category,
label: localize(
`ui.panel.lovelace.strategy.device.categories.${category}`
),
})),
},
},
},
{
name: "display",
selector: {
select: {
mode: "box",
options: (["tiles", "list"] as const).map((display) => ({
value: display,
label: localize(
`ui.panel.lovelace.editor.strategy.device.display_options.${display}`
),
description: localize(
`ui.panel.lovelace.editor.strategy.device.display_options.${display}_description`
),
})),
},
},
},
{
name: "group_by_category",
selector: { boolean: {} },
},
...(deviceEntities.length
? ([
{
name: "entities",
type: "expandable",
flatten: true,
iconPath: mdiFormatListBulleted,
schema: [
{
name: "exclude_entities",
selector: {
entity: {
multiple: true,
include_entities: deviceEntities,
},
},
},
],
},
] as const)
: []),
] as const satisfies readonly HaFormSchema[]
);
protected render() {
if (!this._config) return nothing;
const data = {
categories: DEFAULT_DEVICE_SECTION_CATEGORIES,
display: "tiles",
heading: "device",
...this._config,
};
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${this._schema(
this._localize,
this._deviceEntities(
this._config.device_id,
this.hass?.entities ?? {}
)
)}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _computeLabel = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) =>
this._localize(`ui.panel.lovelace.editor.strategy.device.${schema.name}`);
private _computeHelper = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) =>
schema.name === "group_by_category" ||
schema.name === "heading" ||
schema.name === "exclude_entities"
? this._localize(
`ui.panel.lovelace.editor.strategy.device.${schema.name}_helper`
)
: "";
private _valueChanged(ev: ValueChangedEvent<DeviceSectionStrategyConfig>) {
ev.stopPropagation();
const config = { ...this._config, ...ev.detail.value };
if (config.device_id !== this._config?.device_id) {
delete config.exclude_entities;
}
fireEvent(this, "config-changed", { config });
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-device-section-strategy-editor": HuiDeviceSectionStrategyEditor;
}
}
@@ -76,6 +76,7 @@ const STRATEGIES: Record<LovelaceStrategyConfigType, Record<string, any>> = {
section: {
"common-controls": () =>
import("./usage_prediction/common-controls-section-strategy"),
device: () => import("./device/device-section-strategy"),
},
};
+34
View File
@@ -9563,6 +9563,17 @@
"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."
},
"device": {
"no_device": "Select a device to show its entities.",
"device_not_found": "This device no longer exists.",
"categories": {
"control": "[%key:ui::panel::config::devices::entities::control%]",
"sensor": "[%key:ui::panel::config::devices::entities::sensor%]",
"event": "[%key:ui::panel::config::devices::entities::event%]",
"config": "[%key:ui::panel::config::devices::entities::config%]",
"diagnostic": "[%key:ui::panel::config::devices::entities::diagnostic%]"
}
},
"light": {
"lights": "Lights",
"other_lights": "Other lights",
@@ -11556,6 +11567,29 @@
"exclude_entities_helper": "These entities will not be suggested based on your activity",
"hide_empty": "Hide when empty"
},
"device": {
"device_id": "Device",
"categories": "Categories",
"display": "Display",
"display_options": {
"tiles": "Tiles",
"tiles_description": "One card per entity",
"list": "List",
"list_description": "All entities in a single card"
},
"group_by_category": "Group by category",
"group_by_category_helper": "Adds a subtitle above each category",
"heading": "Heading",
"heading_helper": "When shown, the heading includes the battery level",
"heading_options": {
"none": "No heading",
"device": "Device name",
"area": "Area and device name"
},
"entities": "Entities",
"exclude_entities": "Exclude entities",
"exclude_entities_helper": "Not shown in this section, even if their category is selected"
},
"original-states": {
"areas": "Areas to display",
"hide_entities_without_area": "Hide entities without area",