mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-25 07:04:11 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c54670bc61 | ||
|
|
9b5f0e65a8 | ||
|
|
f7d7a5df3f | ||
|
|
9ac138db2a | ||
|
|
c834dd6953 | ||
|
|
6e617f8795 | ||
|
|
3cc7ee9865 | ||
|
|
89dbb5e8b1 | ||
|
|
881c7b8ad0 | ||
|
|
73f65ba7c6 |
No files matched your search
@@ -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"
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -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"),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user