Compare commits

...
12 changed files with 1017 additions and 50 deletions

No files matched your search

+17 -9
View File
@@ -86,6 +86,8 @@ export class HaEntityNamePicker extends LitElement {
@property({ type: Boolean, reflect: true }) public disabled = false;
@property({ attribute: false }) public allowCustom = true;
@state() private _mode?: "composed" | "custom";
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@@ -129,17 +131,23 @@ export class HaEntityNamePicker extends LitElement {
<div class="container">
<div class="header">
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-button-toggle-group
size="s"
.buttons=${modeButtons}
.active=${this._mode}
.disabled=${this.disabled}
@value-changed=${this._modeChanged}
></ha-button-toggle-group>
${
this.allowCustom
? html`
<ha-button-toggle-group
size="s"
.buttons=${modeButtons}
.active=${this._mode}
.disabled=${this.disabled}
@value-changed=${this._modeChanged}
></ha-button-toggle-group>
`
: nothing
}
</div>
<div class="content">
${
this._mode === "custom"
this.allowCustom && this._mode === "custom"
? this._renderTextInput()
: this._renderPicker()
}
@@ -211,7 +219,7 @@ export class HaEntityNamePicker extends LitElement {
.rowRenderer=${rowRenderer}
.value=${this._getPickerValue()}
no-sort
allow-custom-value
?allow-custom-value=${this.allowCustom}
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.custom_name"
)}
+332
View File
@@ -0,0 +1,332 @@
import type { ContextType } from "@lit/context";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { computeRTL } from "../common/util/compute_rtl";
import { internationalizationContext, statesContext } from "../data/context";
import { NO_DEVICE_CLASS } from "../data/entity/device_class";
import {
computeEntityTypeNames,
entityTypeKey,
FIXED_ENTITY_TYPE_ICONS,
parseEntityType,
sortDeviceClasses,
usedEntityTypes,
} from "../data/entity/entity_type";
import { domainToName } from "../data/integration";
import "./chips/ha-chip-set";
import "./chips/ha-input-chip";
import "./ha-combo-box-item";
import "./ha-domain-icon";
import "./ha-generic-picker";
import type {
PickerComboBoxItem,
PickerComboBoxSearchFn,
} from "./ha-picker-combo-box";
import { DEFAULT_SEARCH_KEYS } from "./ha-picker-combo-box";
import "./ha-tree-indicator";
interface EntityTypeComboBoxItem extends PickerComboBoxItem {
last?: boolean;
}
const SEARCH_KEYS = [
...DEFAULT_SEARCH_KEYS,
{ name: "search_labels.domain", weight: 5 },
];
const isDeviceClassOf = (
item: PickerComboBoxItem | undefined,
domain: string
) => {
if (!item) {
return false;
}
const type = parseEntityType(item.id);
return type.domain === domain && type.deviceClass !== undefined;
};
// Flags the last device class of each domain so its tree line ends there.
const markLastDeviceClasses = (
items: PickerComboBoxItem[]
): EntityTypeComboBoxItem[] =>
items.map((item, index) => {
const { domain, deviceClass } = parseEntityType(item.id);
if (!deviceClass) {
return item;
}
return { ...item, last: !isDeviceClassOf(items[index + 1], domain) };
});
@customElement("ha-entity-type-picker")
export class HaEntityTypePicker extends LitElement {
@property({ attribute: false }) public value?: string[];
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@consume({ context: statesContext, subscribe: true })
private _states?: ContextType<typeof statesContext>;
private _titlesLoaded = false;
protected willUpdate() {
if (this._titlesLoaded || !this._i18n) {
return;
}
this._titlesLoaded = true;
this._i18n.loadBackendTranslation("title");
}
private _chipLabels = memoizeOne(computeEntityTypeNames);
private _items = memoizeOne(
(
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string
): PickerComboBoxItem[] => {
// A section keeps following its types, so a device class is offered even
// when it is the only one of its domain today.
const types = usedEntityTypes(states, false);
const domains = [...types.keys()]
.map((domain) => ({ domain, name: domainToName(localize, domain) }))
.sort((a, b) => stringCompare(a.name, b.name, language));
return domains.flatMap(({ domain, name }) => [
{ id: domain, primary: name },
...sortDeviceClasses(
localize,
domain,
types
.get(domain)!
.filter((deviceClass) => deviceClass !== NO_DEVICE_CLASS),
language
).map(({ deviceClass, name: deviceClassName }) => ({
id: entityTypeKey(domain, deviceClass),
primary: deviceClassName,
search_labels: { domain: name },
})),
]);
}
);
private _availableItems = memoizeOne(
(items: PickerComboBoxItem[], value: string[]) => {
const selectedDomains = new Set(
value.filter((key) => !parseEntityType(key).deviceClass)
);
return markLastDeviceClasses(
items.filter(
(item) =>
!value.includes(item.id) &&
!selectedDomains.has(parseEntityType(item.id).domain)
)
);
}
);
private _getItems = () => {
if (!this._states || !this._i18n) {
return [];
}
const items = this._items(
this._states,
this._i18n.localize,
this._i18n.locale.language
);
return this._availableItems(items, this.value ?? []);
};
// Keeps the domain above the device classes that match, so the tree stays
// readable while searching, and lists the domains by their best match.
// Items the search adds itself, like the "no match" placeholder, are kept
// after the list.
private _searchFn: PickerComboBoxSearchFn<PickerComboBoxItem> = (
_search,
filteredItems,
allItems
) => {
const matchedIds = new Set(filteredItems.map((item) => item.id));
const matchedDomains = new Set(
filteredItems.map((item) => parseEntityType(item.id).domain)
);
const ordered = [...matchedDomains].flatMap((matchedDomain) =>
allItems.filter((item) => {
const { domain, deviceClass } = parseEntityType(item.id);
return (
domain === matchedDomain && (!deviceClass || matchedIds.has(item.id))
);
})
);
const allIds = new Set(allItems.map((item) => item.id));
const extras = filteredItems.filter((item) => !allIds.has(item.id));
return [...markLastDeviceClasses(ordered), ...extras];
};
private _rowRenderer: RenderItemFunction<EntityTypeComboBoxItem> = (item) => {
const { deviceClass } = parseEntityType(item.id);
const rtl = this._i18n
? computeRTL(
this._i18n.language,
this._i18n.translationMetadata.translations
)
: false;
return html`
<ha-combo-box-item
style=${
deviceClass
? "--ha-combo-box-item-padding-inline-start: var(--ha-space-12);"
: ""
}
>
${
deviceClass
? html`
<ha-tree-indicator
style=${styleMap({
width: "var(--ha-space-12)",
position: "absolute",
top: "0",
height: "100%",
left: rtl ? undefined : "var(--ha-space-1)",
right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${item.last}
slot="start"
></ha-tree-indicator>
`
: nothing
}
${this._renderIcon(item.id, "start")}
<span slot="headline">${item.primary}</span>
</ha-combo-box-item>
`;
};
private _renderIcon(key: string, slot: "start" | "icon") {
const { domain, deviceClass } = parseEntityType(key);
return html`
<ha-domain-icon
slot=${slot}
.icon=${FIXED_ENTITY_TYPE_ICONS[key]}
.domain=${domain}
.deviceClass=${deviceClass}
.state=${domain === "binary_sensor" ? "on" : undefined}
?brand-fallback=${!deviceClass}
></ha-domain-icon>
`;
}
protected render() {
const localize = this._i18n?.localize;
const value = this.value ?? [];
const chipLabels = localize ? this._chipLabels(localize, value) : undefined;
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
${
value.length && chipLabels
? html`
<ha-chip-set>
${repeat(
value,
(key) => key,
(key) => {
const label = chipLabels.get(key)!;
return html`
<ha-input-chip
data-key=${key}
.label=${label}
.disabled=${this.disabled}
@remove=${this._removeItem}
selected
>
${this._renderIcon(key, "icon")} ${label}
</ha-input-chip>
`;
}
)}
</ha-chip-set>
`
: nothing
}
<ha-generic-picker
.helper=${this.helper}
.disabled=${this.disabled}
.value=${""}
.addButtonLabel=${localize?.("ui.components.entity-type-picker.add")}
.getItems=${this._getItems}
.rowRenderer=${this._rowRenderer}
.searchFn=${this._searchFn}
.searchKeys=${SEARCH_KEYS}
.emptyLabel=${localize?.("ui.components.entity-type-picker.no_types")}
no-sort
@value-changed=${this._itemAdded}
></ha-generic-picker>
`;
}
private _itemAdded(ev: CustomEvent<{ value?: string }>) {
ev.stopPropagation();
const key = ev.detail.value;
if (!key) {
return;
}
const { domain, deviceClass } = parseEntityType(key);
const value = (this.value ?? []).filter(
(selected) => deviceClass || parseEntityType(selected).domain !== domain
);
this._setValue([...value, key]);
}
private _removeItem(ev: Event) {
ev.stopPropagation();
const key = (ev.currentTarget as HTMLElement).dataset.key;
this._setValue((this.value ?? []).filter((selected) => selected !== key));
}
private _setValue(value: string[]) {
this.value = value;
fireEvent(this, "value-changed", { value });
}
static styles = css`
:host {
display: block;
}
label {
display: block;
margin-bottom: var(--ha-space-2);
font-weight: var(--ha-font-weight-medium);
}
ha-generic-picker {
display: block;
width: 100%;
}
ha-chip-set {
padding: var(--ha-space-2) 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-entity-type-picker": HaEntityTypePicker;
}
}
+10 -35
View File
@@ -23,13 +23,12 @@ import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, statesContext } from "../data/context";
import {
computeDeviceClassName,
NO_DEVICE_CLASS,
} from "../data/entity/device_class";
import { NO_DEVICE_CLASS } from "../data/entity/device_class";
import {
entityTypeKey,
FIXED_ENTITY_TYPE_ICONS,
parseEntityType,
sortDeviceClasses,
usedEntityTypes,
} from "../data/entity/entity_type";
import { domainToName } from "../data/integration";
@@ -45,11 +44,6 @@ import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
// Core picks this one from the battery level, so it has no usable default.
const FIXED_TYPE_ICONS: Record<string, string> = {
"sensor/battery": "mdi:battery",
};
interface TypeRow {
key: string;
domain: string;
@@ -180,7 +174,7 @@ export class HaFilterEntityTypes extends LitElement {
? html`<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>`
: html`<ha-domain-icon
slot="start"
.icon=${FIXED_TYPE_ICONS[row.key]}
.icon=${FIXED_ENTITY_TYPE_ICONS[row.key]}
.domain=${row.domain}
.deviceClass=${row.deviceClass}
.state=${row.domain === "binary_sensor" ? "on" : undefined}
@@ -240,21 +234,12 @@ export class HaFilterEntityTypes extends LitElement {
const rows: TypeRow[] = [];
for (const { domain, name } of domains) {
const deviceClasses = types
.get(domain)!
.map((deviceClass) => ({
deviceClass,
name: this._deviceClassName(localize, domain, deviceClass),
}))
.sort((a, b) => {
if (a.deviceClass === NO_DEVICE_CLASS) {
return 1;
}
if (b.deviceClass === NO_DEVICE_CLASS) {
return -1;
}
return stringCompare(a.name, b.name, language);
});
const deviceClasses = sortDeviceClasses(
localize,
domain,
types.get(domain)!,
language
);
const matchingClasses = deviceClasses.filter((entry) =>
this._matches(filter, entry.deviceClass, entry.name)
@@ -298,16 +283,6 @@ export class HaFilterEntityTypes extends LitElement {
}
);
private _deviceClassName(
localize: LocalizeFunc,
domain: string,
deviceClass: string
): string {
return deviceClass === NO_DEVICE_CLASS
? localize("ui.components.filter-entity-types.no_device_class")
: computeDeviceClassName(localize, domain, deviceClass);
}
private _matches(
filter: string | undefined,
slug: string,
@@ -34,6 +34,7 @@ export class HaSelectorEntityName extends SubscribeMixin(LitElement) {
}
.value=${this.value}
.defaultValue=${this.selector.entity_name?.default_name}
.allowCustom=${this.selector.entity_name?.allow_custom ?? true}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
@@ -140,6 +140,11 @@ export class HaTargetSelector extends LitElement {
);
static styles = css`
label {
display: block;
margin-bottom: var(--ha-space-2);
font-weight: var(--ha-font-weight-medium);
}
ha-target-picker {
display: block;
}
+88 -4
View File
@@ -1,7 +1,11 @@
import { STRINGS_SEPARATOR_DOT } from "../../common/const";
import { computeDomain } from "../../common/entity/compute_domain";
import { stringCompare } from "../../common/string/compare";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { HomeAssistant } from "../../types";
import { DOMAIN_DEVICE_CLASSES } from "../device_classes";
import { NO_DEVICE_CLASS } from "./device_class";
import { domainToName } from "../integration";
import { computeDeviceClassName, NO_DEVICE_CLASS } from "./device_class";
const SEPARATOR = "/";
@@ -26,9 +30,11 @@ export const parseEntityType = (key: string): EntityType => {
export const entityTypesNeedStates = (types?: string[]): boolean =>
!!types?.some((key) => key.includes(SEPARATOR));
// A domain worth no split maps to an empty list rather than to its lone bucket.
// By default, a domain worth no split maps to an empty list rather than to its
// lone bucket.
export const usedEntityTypes = (
states: HomeAssistant["states"]
states: HomeAssistant["states"],
collapseSingleClass = true
): Map<string, string[]> => {
const byDomain = new Map<string, Set<string>>();
@@ -47,7 +53,7 @@ export const usedEntityTypes = (
return new Map(
[...byDomain].map(([domain, classes]) => [
domain,
classes.size > 1 ? [...classes] : [],
classes.size > 1 || !collapseSingleClass ? [...classes] : [],
])
);
};
@@ -90,3 +96,81 @@ export const entityTypeFilterFunc = (
return classes.has(stateObj.attributes.device_class || NO_DEVICE_CLASS);
};
};
// Core picks this one from the battery level, so it has no usable default.
export const FIXED_ENTITY_TYPE_ICONS: Record<string, string> = {
"sensor/battery": "mdi:battery",
};
export const computeEntityTypeDeviceClassName = (
localize: LocalizeFunc,
domain: string,
deviceClass: string
): string =>
deviceClass === NO_DEVICE_CLASS
? localize("ui.components.filter-entity-types.no_device_class")
: computeDeviceClassName(localize, domain, deviceClass);
export const sortDeviceClasses = (
localize: LocalizeFunc,
domain: string,
deviceClasses: string[],
language?: string
): { deviceClass: string; name: string }[] =>
deviceClasses
.map((deviceClass) => ({
deviceClass,
name: computeEntityTypeDeviceClassName(localize, domain, deviceClass),
}))
.sort((a, b) => {
if (a.deviceClass === NO_DEVICE_CLASS) {
return 1;
}
if (b.deviceClass === NO_DEVICE_CLASS) {
return -1;
}
return stringCompare(a.name, b.name, language);
});
const computeEntityTypeFullName = (
localize: LocalizeFunc,
key: string
): string => {
const { domain, deviceClass } = parseEntityType(key);
const domainName = domainToName(localize, domain);
if (!deviceClass) {
return domainName;
}
return [
domainName,
computeEntityTypeDeviceClassName(localize, domain, deviceClass),
].join(STRINGS_SEPARATOR_DOT);
};
// A device class is named on its own unless another type in the list has the
// same name, or it is the "no type" bucket, which means nothing without its
// domain.
export const computeEntityTypeNames = (
localize: LocalizeFunc,
keys: string[]
): Map<string, string> => {
const shortNames = keys.map((key) => {
const { domain, deviceClass } = parseEntityType(key);
return deviceClass
? computeEntityTypeDeviceClassName(localize, domain, deviceClass)
: domainToName(localize, domain);
});
return new Map(
keys.map((key, index) => {
const name = shortNames[index];
const isDuplicate =
shortNames.indexOf(name) !== shortNames.lastIndexOf(name);
const needsDomain =
isDuplicate || parseEntityType(key).deviceClass === NO_DEVICE_CLASS;
return [
key,
needsDomain ? computeEntityTypeFullName(localize, key) : name,
];
})
);
};
+2 -1
View File
@@ -290,7 +290,7 @@ export const getDurationSelectorUnits = (
enableMillisecond: !!config?.enable_millisecond,
});
interface EntitySelectorFilter {
export interface EntitySelectorFilter {
integration?: string;
domain?: string | readonly string[];
device_class?: string | readonly string[];
@@ -691,6 +691,7 @@ export interface EntityNameSelector {
entity_name: {
entity_id?: string;
default_name?: EntityNameItem | EntityNameItem[] | string;
allow_custom?: boolean;
} | null;
}
@@ -19,7 +19,7 @@ import { showCreateSectionDialog } from "./show-create-section-dialog";
import { showEditSectionDialog } from "./show-edit-section-dialog";
interface BuiltInSectionStrategy {
type: "common-controls" | "device";
type: "common-controls" | "device" | "entities";
isAvailable?: (hass: HomeAssistant) => boolean;
}
@@ -31,6 +31,9 @@ const BUILT_IN_SECTION_STRATEGIES: BuiltInSectionStrategy[] = [
{
type: "device",
},
{
type: "entities",
},
];
interface StrategyOption {
@@ -0,0 +1,350 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { ensureArray } from "../../../../common/array/ensure-array";
import { getAreasFloorHierarchy } from "../../../../common/areas/areas-floor-hierarchy";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { computeEntityNameDisplay } from "../../../../common/entity/compute_entity_name_display";
import type { EntityNameItem } from "../../../../common/entity/entity_name_config";
import { getEntityAreaId } from "../../../../common/entity/context/get_entity_context";
import { stringCompare } from "../../../../common/string/compare";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import { NO_DEVICE_CLASS } from "../../../../data/entity/device_class";
import {
computeEntityTypeNames,
entityTypeFilterFunc,
entityTypeKey,
} from "../../../../data/entity/entity_type";
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import { resolveEntityIDs } from "../../../../data/selector";
import type { HassServiceTargetWithExclude } from "../../../../data/target";
import type { HomeAssistant } from "../../../../types";
import type {
HeadingCardConfig,
MarkdownCardConfig,
TileCardConfig,
} from "../../cards/types";
import type {
LovelaceStrategyDependency,
LovelaceStrategyEditor,
} from "../types";
export const ENTITIES_SECTION_GROUPS = [
"none",
"area",
"floor",
"type",
] as const;
export type EntitiesSectionGroup = (typeof ENTITIES_SECTION_GROUPS)[number];
export interface EntitiesSectionStrategyConfig {
type: "entities";
title?: string;
target?: HassServiceTargetWithExclude;
entity_types?: string[];
include_config_and_diagnostic?: boolean;
group_by?: EntitiesSectionGroup;
name?: "" | EntityNameItem | EntityNameItem[];
}
interface EntityGroup {
name?: string;
entityIds: string[];
}
const TARGET_KEYS = [
"entity_id",
"device_id",
"area_id",
"floor_id",
"label_id",
] as const satisfies readonly (keyof HassServiceTarget)[];
const hasTargets = (target: HassServiceTarget): boolean =>
TARGET_KEYS.some((key) => ensureArray(target[key] ?? []).length > 0);
const computeTileConfig = (
entityId: string,
name?: EntitiesSectionStrategyConfig["name"]
): TileCardConfig => ({
type: "tile",
entity: entityId,
...(name !== undefined ? { name } : {}),
});
const addToGroup = <T>(groups: Map<T, string[]>, key: T, entityId: string) => {
const entityIds = groups.get(key);
if (entityIds) {
entityIds.push(entityId);
return;
}
groups.set(key, [entityId]);
};
// Groups follow the order of the picked types, each entity going to the type
// that picked it: its domain, or its domain and device class.
const groupByType = (
hass: HomeAssistant,
localize: LocalizeFunc,
entityIds: string[],
types: string[]
): EntityGroup[] => {
const byType = new Map<string, string[]>();
for (const entityId of entityIds) {
const domain = computeDomain(entityId);
if (types.includes(domain)) {
addToGroup(byType, domain, entityId);
continue;
}
const deviceClass =
hass.states[entityId]?.attributes.device_class || NO_DEVICE_CLASS;
addToGroup(byType, entityTypeKey(domain, deviceClass), entityId);
}
const names = computeEntityTypeNames(localize, types);
return types
.filter((type) => byType.has(type))
.map((type) => ({ name: names.get(type), entityIds: byType.get(type)! }));
};
// Groups follow the floor and area order of the home. Entities without a
// known area or floor go to a last group.
const groupByPlace = (
hass: HomeAssistant,
entityIds: string[],
groupBy: "area" | "floor"
): EntityGroup[] => {
const hierarchy = getAreasFloorHierarchy(
Object.values(hass.floors),
Object.values(hass.areas)
);
const orderedGroupIds =
groupBy === "area"
? [
...hierarchy.floors.flatMap((floor) => floor.areas),
...hierarchy.areas,
]
: hierarchy.floors.map((floor) => floor.id);
const knownGroupIds = new Set(orderedGroupIds);
const groupIdOf = (entityId: string): string | undefined => {
const areaId = getEntityAreaId(entityId, hass.entities, hass.devices);
if (!areaId) {
return undefined;
}
const groupId =
groupBy === "area" ? areaId : (hass.areas[areaId]?.floor_id ?? undefined);
return groupId && knownGroupIds.has(groupId) ? groupId : undefined;
};
const byGroup = new Map<string | undefined, string[]>();
for (const entityId of entityIds) {
addToGroup(byGroup, groupIdOf(entityId), entityId);
}
const groupName = (groupId: string) =>
groupBy === "area" ? hass.areas[groupId]?.name : hass.floors[groupId]?.name;
const groups: EntityGroup[] = orderedGroupIds
.filter((groupId) => byGroup.has(groupId))
.map((groupId) => ({
name: groupName(groupId),
entityIds: byGroup.get(groupId)!,
}));
const ungrouped = byGroup.get(undefined);
if (ungrouped) {
groups.push({
name: hass.localize("ui.panel.lovelace.strategy.entities.other"),
entityIds: ungrouped,
});
}
return groups;
};
const findAreaWithMostLights = (hass: HomeAssistant): string | undefined => {
const lightCountByArea = new Map<string, number>();
for (const entry of Object.values(hass.entities)) {
if (computeDomain(entry.entity_id) !== "light" || entry.hidden) {
continue;
}
const areaId = getEntityAreaId(
entry.entity_id,
hass.entities,
hass.devices
);
if (areaId) {
lightCountByArea.set(areaId, (lightCountByArea.get(areaId) ?? 0) + 1);
}
}
let bestAreaId: string | undefined;
let bestCount = 0;
for (const [areaId, count] of lightCountByArea) {
if (count > bestCount) {
bestAreaId = areaId;
bestCount = count;
}
}
return bestAreaId;
};
@customElement("entities-section-strategy")
export class EntitiesSectionStrategy extends ReactiveElement {
static registryDependencies: readonly LovelaceStrategyDependency[] = [
"entities",
"devices",
"areas",
"floors",
];
public static async getConfigElement(): Promise<LovelaceStrategyEditor> {
await import("./hui-entities-section-strategy-editor");
return document.createElement("hui-entities-section-strategy-editor");
}
public static getStubConfig(
hass: HomeAssistant
): EntitiesSectionStrategyConfig {
const areaId = findAreaWithMostLights(hass) ?? Object.keys(hass.areas)[0];
const config: EntitiesSectionStrategyConfig = {
type: "entities",
entity_types: ["light"],
};
if (areaId) {
return { ...config, target: { area_id: areaId } };
}
return config;
}
static async generate(
config: EntitiesSectionStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceSectionConfig> {
const section: LovelaceSectionConfig = {
type: "grid",
cards: [],
};
if (config.title) {
section.cards!.push({
type: "heading",
heading: config.title,
} satisfies HeadingCardConfig);
}
if (
!config.target ||
!hasTargets(config.target) ||
!config.entity_types?.length
) {
section.cards!.push({
type: "markdown",
content: hass.localize(
"ui.panel.lovelace.strategy.entities.no_criteria"
),
} satisfies MarkdownCardConfig);
return section;
}
const explicitEntityIds = new Set(
ensureArray(config.target?.entity_id ?? [])
);
const targetLabelIds = ensureArray(config.target?.label_id ?? []);
const matchesType = entityTypeFilterFunc(config.entity_types, hass.states);
const isShown = (entityId: string) => {
if (explicitEntityIds.has(entityId)) {
return true;
}
const entry = hass.entities[entityId];
if (!entry) {
return true;
}
if (entry.hidden) {
return false;
}
if (config.include_config_and_diagnostic) {
return true;
}
const directlyLabelled = targetLabelIds.some((labelId) =>
entry.labels.includes(labelId)
);
return directlyLabelled || !entry.entity_category;
};
const shownEntityIds = resolveEntityIDs(
hass,
config.target,
hass.entities,
hass.devices,
hass.areas
)
.filter(matchesType)
.filter(isShown);
// An empty name hides the name on the tiles, which are then sorted by
// their default name.
const names = new Map(
shownEntityIds.map((entityId) => {
const stateObj = hass.states[entityId];
const name = stateObj
? computeEntityNameDisplay(stateObj, config.name || undefined, hass)
: entityId;
return [entityId, name];
})
);
const entityIds = shownEntityIds.sort((entityId1, entityId2) =>
stringCompare(
names.get(entityId1)!,
names.get(entityId2)!,
hass.locale.language
)
);
if (!entityIds.length) {
section.cards!.push({
type: "markdown",
content: hass.localize(
"ui.panel.lovelace.strategy.entities.no_entities"
),
} satisfies MarkdownCardConfig);
return section;
}
const groupBy = config.group_by ?? "none";
let groups: EntityGroup[] = [{ entityIds }];
if (groupBy === "type") {
const localize = await hass.loadBackendTranslation("title");
groups = groupByType(hass, localize, entityIds, config.entity_types);
} else if (groupBy !== "none") {
groups = groupByPlace(hass, entityIds, groupBy);
}
const showGroupNames = groups.length > 1;
for (const group of groups) {
if (showGroupNames && group.name) {
section.cards!.push({
type: "heading",
heading_style: "subtitle",
heading: group.name,
} satisfies HeadingCardConfig);
}
section.cards!.push(
...group.entityIds.map((entityId) =>
computeTileConfig(entityId, config.name)
)
);
}
return section;
}
}
declare global {
interface HTMLElementTagNameMap {
"entities-section-strategy": EntitiesSectionStrategy;
}
}
@@ -0,0 +1,179 @@
import { css, 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-entity-type-picker";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import { parseEntityType } from "../../../../data/entity/entity_type";
import type { EntitySelectorFilter } from "../../../../data/selector";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { LovelaceStrategyEditor } from "../types";
import {
ENTITIES_SECTION_GROUPS,
type EntitiesSectionStrategyConfig,
} from "./entities-section-strategy";
const TITLE_SCHEMA = [
{ name: "title", selector: { text: {} } },
] as const satisfies readonly HaFormSchema[];
const computeEntityFilters = (
types: string[] | undefined
): EntitySelectorFilter[] | undefined =>
types?.map((key) => {
const { domain, deviceClass } = parseEntityType(key);
if (!deviceClass) {
return { domain };
}
return { domain, device_class: deviceClass };
});
@customElement("hui-entities-section-strategy-editor")
export class HuiEntitiesSectionStrategyEditor
extends LitElement
implements LovelaceStrategyEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@state() private _config?: EntitiesSectionStrategyConfig;
public setConfig(config: EntitiesSectionStrategyConfig): void {
this._config = config;
}
private _schema = memoizeOne(
(
localize: LocalizeFunc,
types: string[] | undefined,
includeConfigAndDiagnostic: boolean
) =>
[
{
name: "include_config_and_diagnostic",
selector: { boolean: {} },
},
{
name: "target",
required: true,
selector: {
target: {
entity: computeEntityFilters(types),
primary_entities_only: !includeConfigAndDiagnostic,
},
},
},
{
name: "name",
selector: {
entity_name: {
allow_custom: false,
},
},
},
{
name: "group_by",
required: true,
selector: {
select: {
mode: "dropdown",
options: ENTITIES_SECTION_GROUPS.map((group) => ({
value: group,
label: localize(
`ui.panel.lovelace.editor.strategy.entities.group_by_options.${group}`
),
})),
},
},
},
] as const satisfies readonly HaFormSchema[]
);
protected render() {
if (!this._config) return nothing;
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${TITLE_SCHEMA}
.computeLabel=${this._computeLabel}
@value-changed=${this._valueChanged}
></ha-form>
<ha-entity-type-picker
.label=${this._localize(
"ui.panel.lovelace.editor.strategy.entities.entity_types"
)}
.value=${this._config.entity_types}
@value-changed=${this._typesChanged}
></ha-entity-type-picker>
<ha-form
.hass=${this.hass}
.data=${{ group_by: "none", ...this._config }}
.schema=${this._schema(
this._localize,
this._config.entity_types,
Boolean(this._config.include_config_and_diagnostic)
)}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _computeLabel = (
schema:
| SchemaUnion<typeof TITLE_SCHEMA>
| SchemaUnion<ReturnType<typeof this._schema>>
) =>
this._localize(`ui.panel.lovelace.editor.strategy.entities.${schema.name}`);
private _computeHelper = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) =>
schema.name === "include_config_and_diagnostic"
? this._localize(
`ui.panel.lovelace.editor.strategy.entities.${schema.name}_helper`
)
: "";
private _typesChanged(ev: ValueChangedEvent<string[]>) {
ev.stopPropagation();
const config = { ...this._config! };
if (ev.detail.value.length) {
config.entity_types = ev.detail.value;
} else {
delete config.entity_types;
}
fireEvent(this, "config-changed", { config });
}
private _valueChanged(ev: ValueChangedEvent<EntitiesSectionStrategyConfig>) {
ev.stopPropagation();
const config = { ...this._config, ...ev.detail.value };
if (config.group_by === "none") {
delete config.group_by;
}
fireEvent(this, "config-changed", { config });
}
static styles = css`
ha-entity-type-picker {
margin: var(--ha-space-6) 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-entities-section-strategy-editor": HuiEntitiesSectionStrategyEditor;
}
}
@@ -77,6 +77,7 @@ const STRATEGIES: Record<LovelaceStrategyConfigType, Record<string, any>> = {
"common-controls": () =>
import("./usage_prediction/common-controls-section-strategy"),
device: () => import("./device/device-section-strategy"),
entities: () => import("./entities/entities-section-strategy"),
},
};
+28
View File
@@ -928,6 +928,10 @@
"expand": "Expand",
"collapse": "Collapse"
},
"entity-type-picker": {
"add": "Add type",
"no_types": "No types available"
},
"subpage-data-table": {
"filters": "Filters",
"show_results": "Show {number} results",
@@ -9841,6 +9845,11 @@
"diagnostic": "[%key:ui::panel::config::devices::entities::diagnostic%]"
}
},
"entities": {
"other": "Other",
"no_criteria": "Pick at least one type and one target.",
"no_entities": "No entities match this section."
},
"light": {
"lights": "Lights",
"other_lights": "Other lights",
@@ -10437,6 +10446,10 @@
"device": {
"name": "Device",
"description": "Shows the controls and sensors of a device."
},
"entities": {
"name": "Entities",
"description": "Shows the entities of the types you pick, like all the lights in a room."
}
},
"create_section": {
@@ -11929,6 +11942,21 @@
"exclude_entities": "Exclude entities",
"exclude_entities_helper": "Not shown in this section, even if their category is selected"
},
"entities": {
"title": "Title",
"entity_types": "Types to show",
"target": "[%key:ui::panel::config::automation::editor::targets%]",
"group_by": "Group by",
"name": "Display name",
"group_by_options": {
"none": "No grouping",
"area": "Area",
"floor": "Floor",
"type": "Type"
},
"include_config_and_diagnostic": "Include configuration and diagnostic entities",
"include_config_and_diagnostic_helper": "By default, these only show when you add them as entities or through a label"
},
"original-states": {
"areas": "Areas to display",
"hide_entities_without_area": "Hide entities without area",