Add support for labels (#20189)

* Add support for labels

* Update ha-label-picker.ts

* Remove aliases from label

* Use opacity for chips in labels picker

* Fix label filtering in target picker

* Update ha-labels-picker.ts

* Update dialog-area-registry-detail.ts

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
This commit is contained in:
Bram Kragten 2024-03-26 20:52:17 +01:00 committed by GitHub
parent b239ec2b71
commit eb4ae926b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1774 additions and 134 deletions

View File

@ -72,6 +72,7 @@ export class HaDemo extends HomeAssistantAppEl {
id: "sensor.co2_intensity",
name: null,
icon: null,
labels: [],
platform: "co2signal",
hidden_by: null,
entity_category: null,
@ -88,6 +89,7 @@ export class HaDemo extends HomeAssistantAppEl {
id: "sensor.co2_intensity",
name: null,
icon: null,
labels: [],
platform: "co2signal",
hidden_by: null,
entity_category: null,

View File

@ -59,6 +59,7 @@ const DEVICES = [
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
},
{
area_id: "backyard",
@ -77,6 +78,7 @@ const DEVICES = [
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
},
{
area_id: null,
@ -95,6 +97,7 @@ const DEVICES = [
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
},
];
@ -106,6 +109,7 @@ const AREAS: AreaRegistryEntry[] = [
icon: null,
picture: null,
aliases: [],
labels: [],
},
{
area_id: "bedroom",
@ -114,6 +118,7 @@ const AREAS: AreaRegistryEntry[] = [
icon: "mdi:bed",
picture: null,
aliases: [],
labels: [],
},
{
area_id: "livingroom",
@ -122,6 +127,7 @@ const AREAS: AreaRegistryEntry[] = [
icon: "mdi:sofa",
picture: null,
aliases: [],
labels: [],
},
];

View File

@ -55,6 +55,7 @@ const DEVICES = [
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
},
{
area_id: "backyard",
@ -73,6 +74,7 @@ const DEVICES = [
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
},
{
area_id: null,
@ -91,6 +93,7 @@ const DEVICES = [
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
},
];
@ -102,6 +105,7 @@ const AREAS: AreaRegistryEntry[] = [
icon: null,
picture: null,
aliases: [],
labels: [],
},
{
area_id: "bedroom",
@ -110,6 +114,7 @@ const AREAS: AreaRegistryEntry[] = [
icon: "mdi:bed",
picture: null,
aliases: [],
labels: [],
},
{
area_id: "livingroom",
@ -118,6 +123,7 @@ const AREAS: AreaRegistryEntry[] = [
icon: "mdi:sofa",
picture: null,
aliases: [],
labels: [],
},
];

View File

@ -406,6 +406,7 @@ export class DemoEntityState extends LitElement {
entity_id: "select.speed",
translation_key: "speed",
platform: "demo",
labels: [],
},
},
});

View File

@ -199,6 +199,7 @@ const createEntityRegistryEntries = (
has_entity_name: false,
unique_id: "updater",
options: null,
labels: [],
},
];
@ -222,6 +223,7 @@ const createDeviceRegistryEntries = (
name_by_user: null,
disabled_by: null,
configuration_url: null,
labels: [],
},
];

View File

@ -19,12 +19,16 @@ export class HaInputChip extends MdInputChip {
var(--rgb-primary-text-color),
0.15
);
--ha-input-chip-selected-container-opacity: 1;
}
/** Set the size of mdc icons **/
::slotted([slot="icon"]) {
display: flex;
--mdc-icon-size: var(--md-input-chip-icon-size, 18px);
}
.selected::before {
opacity: var(--ha-input-chip-selected-container-opacity);
}
`,
];
}

View File

@ -142,6 +142,7 @@ export class HaAreaPicker extends LitElement {
picture: null,
icon: null,
aliases: [],
labels: [],
},
];
}
@ -288,6 +289,7 @@ export class HaAreaPicker extends LitElement {
picture: null,
icon: null,
aliases: [],
labels: [],
},
];
}
@ -303,6 +305,7 @@ export class HaAreaPicker extends LitElement {
picture: null,
icon: "mdi:plus",
aliases: [],
labels: [],
},
];
}

View File

@ -2,17 +2,15 @@ import "@material/mwc-list/mwc-list-item";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import {
computeCssColor,
THEME_COLORS,
} from "../../../common/color/compute-color";
import { fireEvent } from "../../../common/dom/fire_event";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import "../../../components/ha-select";
import { HomeAssistant } from "../../../types";
import { computeCssColor, THEME_COLORS } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import "./ha-select";
import { HomeAssistant } from "../types";
import { LocalizeKeys } from "../common/translations/localize";
@customElement("hui-color-picker")
export class HuiColorPicker extends LitElement {
@customElement("ha-color-picker")
export class HaColorPicker extends LitElement {
@property() public label?: string;
@property() public helper?: string;
@ -21,6 +19,8 @@ export class HuiColorPicker extends LitElement {
@property() public value?: string;
@property({ type: Boolean }) public defaultColor = false;
@property({ type: Boolean }) public disabled = false;
_valueSelected(ev) {
@ -52,16 +52,16 @@ export class HuiColorPicker extends LitElement {
</span>
`
: nothing}
<mwc-list-item value="default">
${this.hass.localize(
`ui.panel.lovelace.editor.color-picker.default_color`
)}
</mwc-list-item>
${this.defaultColor
? html` <mwc-list-item value="default">
${this.hass.localize(`ui.components.color-picker.default_color`)}
</mwc-list-item>`
: nothing}
${Array.from(THEME_COLORS).map(
(color) => html`
<mwc-list-item .value=${color} graphic="icon">
${this.hass.localize(
`ui.panel.lovelace.editor.color-picker.colors.${color}`
`ui.components.color-picker.colors.${color}` as LocalizeKeys
) || color}
<span slot="graphic">${this.renderColorCircle(color)}</span>
</mwc-list-item>
@ -100,6 +100,6 @@ export class HuiColorPicker extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"hui-color-picker": HuiColorPicker;
"ha-color-picker": HaColorPicker;
}
}

View File

@ -0,0 +1,484 @@
import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import { LitElement, PropertyValues, TemplateResult, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import {
ScorableTextItem,
fuzzyFilterSort,
} from "../common/string/filter/sequence-matching";
import {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,
getDeviceEntityDisplayLookup,
} from "../data/device_registry";
import { EntityRegistryDisplayEntry } from "../data/entity_registry";
import {
LabelRegistryEntry,
createLabelRegistryEntry,
subscribeLabelRegistry,
} from "../data/label_registry";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail";
import { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-icon-button";
import "./ha-list-item";
import "./ha-svg-icon";
type ScorableLabelRegistryEntry = ScorableTextItem & LabelRegistryEntry;
const rowRenderer: ComboBoxLitRenderer<LabelRegistryEntry> = (item) =>
html`<ha-list-item
graphic="icon"
class=${classMap({ "add-new": item.label_id === "add_new" })}
>
${item.icon
? html`<ha-icon slot="graphic" .icon=${item.icon}></ha-icon>`
: nothing}
${item.name}
</ha-list-item>`;
@customElement("ha-label-picker")
export class HaLabelPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public value?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: Boolean, attribute: "no-add" })
public noAdd = false;
/**
* Show only labels with entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show no labels with entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
/**
* Show only labels with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
/**
* List of labels to be excluded.
* @type {Array}
* @attr exclude-labels
*/
@property({ type: Array, attribute: "exclude-label" })
public excludeLabels?: string[];
@property({ attribute: false })
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ attribute: false })
public entityFilter?: (entity: HassEntity) => boolean;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@state() private _opened?: boolean;
@state() private _labels?: LabelRegistryEntry[];
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _suggestion?: string;
private _init = false;
public async open() {
await this.updateComplete;
await this.comboBox?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeLabelRegistry(this.hass.connection, (labels) => {
this._labels = labels;
}),
];
}
private _getLabels = memoizeOne(
(
labels: LabelRegistryEntry[],
areas: HomeAssistant["areas"],
devices: DeviceRegistryEntry[],
entities: EntityRegistryDisplayEntry[],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
noAdd: this["noAdd"],
excludeLabels: this["excludeLabels"]
): LabelRegistryEntry[] => {
if (!labels.length) {
return [
{
label_id: "no_labels",
name: this.hass.localize("ui.components.label-picker.no_labels"),
icon: null,
color: null,
},
];
}
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
deviceFilter ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
inputDevices = devices;
inputEntities = entities.filter((entity) => entity.labels.length > 0);
if (includeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
}
if (excludeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return entities.every(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
}
if (includeDeviceClasses) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
}
if (deviceFilter) {
inputDevices = inputDevices!.filter((device) =>
deviceFilter!(device)
);
}
if (entityFilter) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter!(stateObj);
});
}
}
let outputLabels = labels;
const usedLabels = new Set<string>();
let areaIds: string[] | undefined;
if (inputDevices) {
areaIds = inputDevices
.filter((device) => device.area_id)
.map((device) => device.area_id!);
inputDevices.forEach((device) => {
device.labels.forEach((label) => usedLabels.add(label));
});
}
if (inputEntities) {
areaIds = (areaIds ?? []).concat(
inputEntities
.filter((entity) => entity.area_id)
.map((entity) => entity.area_id!)
);
inputEntities.forEach((entity) => {
entity.labels.forEach((label) => usedLabels.add(label));
});
}
if (areaIds) {
areaIds.forEach((areaId) => {
const area = areas[areaId];
area.labels.forEach((label) => usedLabels.add(label));
});
}
if (excludeLabels) {
outputLabels = outputLabels.filter(
(label) => !excludeLabels!.includes(label.label_id)
);
}
if (inputDevices || inputEntities) {
outputLabels = outputLabels.filter((label) =>
usedLabels.has(label.label_id)
);
}
if (!outputLabels.length) {
outputLabels = [
{
label_id: "no_labels",
name: this.hass.localize("ui.components.label-picker.no_match"),
icon: null,
color: null,
},
];
}
return noAdd
? outputLabels
: [
...outputLabels,
{
label_id: "add_new",
name: this.hass.localize("ui.components.label-picker.add_new"),
icon: "mdi:plus",
color: null,
},
];
}
);
protected updated(changedProps: PropertyValues) {
if (
(!this._init && this.hass && this._labels) ||
(this._init && changedProps.has("_opened") && this._opened)
) {
this._init = true;
const labels = this._getLabels(
this._labels!,
this.hass.areas,
Object.values(this.hass.devices),
Object.values(this.hass.entities),
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeLabels
).map((label) => ({
...label,
strings: [label.label_id, label.name],
}));
this.comboBox.items = labels;
this.comboBox.filteredItems = labels;
}
}
protected render(): TemplateResult {
return html`
<ha-combo-box
.hass=${this.hass}
.helper=${this.helper}
item-value-path="label_id"
item-id-path="label_id"
item-label-path="name"
.value=${this._value}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.label-picker.label")
: this.label}
.placeholder=${this.placeholder
? this._labels?.find((label) => label.label_id === this.placeholder)
?.name
: undefined}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._labelChanged}
>
</ha-combo-box>
`;
}
private _filterChanged(ev: CustomEvent): void {
const target = ev.target as HaComboBox;
const filterString = ev.detail.value;
if (!filterString) {
this.comboBox.filteredItems = this.comboBox.items;
return;
}
const filteredItems = fuzzyFilterSort<ScorableLabelRegistryEntry>(
filterString,
target.items || []
);
if (!this.noAdd && filteredItems?.length === 0) {
this._suggestion = filterString;
this.comboBox.filteredItems = [
{
label_id: "add_new_suggestion",
name: this.hass.localize(
"ui.components.label-picker.add_new_sugestion",
{ name: this._suggestion }
),
picture: null,
},
];
} else {
this.comboBox.filteredItems = filteredItems;
}
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _labelChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
let newValue = ev.detail.value;
if (newValue === "no_labels") {
newValue = "";
this.comboBox.setInputValue("");
return;
}
if (!["add_new_suggestion", "add_new"].includes(newValue)) {
if (newValue !== this._value) {
this._setValue(newValue);
}
return;
}
(ev.target as any).value = this._value;
showLabelDetailDialog(this, {
entry: undefined,
suggestedName: newValue === "add_new_suggestion" ? this._suggestion : "",
createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values);
const labels = [...this._labels!, label];
this.comboBox.filteredItems = this._getLabels(
labels,
this.hass.areas!,
Object.values(this.hass.devices)!,
Object.values(this.hass.entities)!,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeLabels
);
await this.updateComplete;
await this.comboBox.updateComplete;
this._setValue(label.label_id);
return label;
},
});
this._suggestion = undefined;
this.comboBox.setInputValue("");
}
private _setValue(value?: string) {
this.value = value;
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-label-picker": HaLabelPicker;
}
}

View File

@ -0,0 +1,213 @@
import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import { LitElement, TemplateResult, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { computeCssColor } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event";
import {
LabelRegistryEntry,
subscribeLabelRegistry,
updateLabelRegistryEntry,
} from "../data/label_registry";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail";
import { HomeAssistant, ValueChangedEvent } from "../types";
import "./chips/ha-chip-set";
import "./chips/ha-input-chip";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-label-picker";
import type { HaLabelPicker } from "./ha-label-picker";
@customElement("ha-labels-picker")
export class HaLabelsPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ attribute: false }) public value?: string[];
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: Boolean, attribute: "no-add" })
public noAdd = false;
/**
* Show only labels with entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show no labels with entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
/**
* Show only labels with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
/**
* List of labels to be excluded.
* @type {Array}
* @attr exclude-labels
*/
@property({ type: Array, attribute: "exclude-label" })
public excludeLabels?: string[];
@property({ attribute: false })
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ attribute: false })
public entityFilter?: (entity: HassEntity) => boolean;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@state() private _labels?: LabelRegistryEntry[];
@query("ha-label-picker", true) public labelPicker!: HaLabelPicker;
public async open() {
await this.updateComplete;
await this.labelPicker?.open();
}
public async focus() {
await this.updateComplete;
await this.labelPicker?.focus();
}
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeLabelRegistry(this.hass.connection, (labels) => {
this._labels = labels;
}),
];
}
protected render(): TemplateResult {
return html`
${this.value?.length
? html`<ha-chip-set>
${repeat(
this.value,
(item) => item,
(item, idx) => {
const label = this._labels?.find(
(lbl) => lbl.label_id === item
);
const color = label?.color
? computeCssColor(label.color)
: undefined;
return html`
<ha-input-chip
.idx=${idx}
.item=${label}
@remove=${this._removeItem}
@click=${this._openDetail}
.label=${label?.name}
selected
style=${color ? `--color: ${color}` : ""}
>
${label?.icon
? html`<ha-icon
slot="icon"
.icon=${label.icon}
></ha-icon>`
: nothing}
</ha-input-chip>
`;
}
)}
</ha-chip-set>`
: nothing}
<ha-label-picker
.hass=${this.hass}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.label-picker.add_label")
: this.label}
.placeholder=${this.placeholder}
.excludeLabels=${this.value}
@value-changed=${this._labelChanged}
>
</ha-label-picker>
`;
}
private get _value() {
return this.value || [];
}
private _removeItem(ev) {
this._value.splice(ev.target.idx, 1);
this._setValue([...this._value]);
}
private _openDetail(ev) {
const label = ev.target.item;
showLabelDetailDialog(this, {
entry: label,
updateEntry: async (values) => {
const updated = await updateLabelRegistryEntry(
this.hass,
label.label_id,
values
);
this._labels = this._labels!.map((lbl) =>
lbl.label_id === updated.label_id ? updated : lbl
);
return updated;
},
});
}
private _labelChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const newValue = ev.detail.value;
if (!newValue || this._value.includes(newValue)) {
return;
}
this._setValue([...this._value, newValue]);
this.labelPicker.value = "";
}
private _setValue(value?: string[]) {
this.value = value;
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
}
static styles = css`
ha-chip-set {
margin-bottom: 8px;
}
ha-input-chip {
border: 1px solid var(--color);
--md-input-chip-selected-container-color: var(--color);
--ha-input-chip-selected-container-opacity: 0.3;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-labels-picker": HaLabelsPicker;
}
}

View File

@ -0,0 +1,83 @@
import { CSSResultGroup, LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import { LabelSelector } from "../../data/selector";
import { HomeAssistant } from "../../types";
import "../ha-labels-picker";
@customElement("ha-selector-label")
export class HaLabelSelector extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property() public value?: string | string[];
@property() public name?: string;
@property() public label?: string;
@property() public placeholder?: string;
@property() public helper?: string;
@property({ attribute: false }) public selector!: LabelSelector;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
protected render() {
if (this.selector.label.multiple) {
return html`
<ha-labels-picker
.hass=${this.hass}
.value=${ensureArray(this.value ?? [])}
.disabled=${this.disabled}
.label=${this.label}
@value-changed=${this._handleChange}
>
</ha-labels-picker>
`;
}
return html`
<ha-label-picker
.hass=${this.hass}
.value=${this.value}
.disabled=${this.disabled}
.label=${this.label}
@value-changed=${this._handleChange}
>
</ha-label-picker>
`;
}
private _handleChange(ev) {
let value = ev.detail.value;
if (this.value === value) {
return;
}
if (
(value === "" || (Array.isArray(value) && value.length === 0)) &&
!this.required
) {
value = undefined;
}
fireEvent(this, "value-changed", { value });
}
static get styles(): CSSResultGroup {
return css`
ha-labels-picker {
display: block;
width: 100%;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-label": HaLabelSelector;
}
}

View File

@ -2,7 +2,7 @@ import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { UiColorSelector } from "../../data/selector";
import "../../panels/lovelace/components/hui-color-picker";
import "../ha-color-picker";
import { HomeAssistant } from "../../types";
@customElement("ha-selector-ui_color")
@ -19,13 +19,14 @@ export class HaSelectorUiColor extends LitElement {
protected render() {
return html`
<hui-color-picker
<ha-color-picker
.label=${this.label}
.hass=${this.hass}
.value=${this.value}
.helper=${this.helper}
.defaultColor=${this.selector.ui_color?.default_color}
@value-changed=${this._valueChanged}
></hui-color-picker>
></ha-color-picker>
`;
}

View File

@ -30,6 +30,7 @@ const LOAD_ELEMENTS = {
entity: () => import("./ha-selector-entity"),
statistic: () => import("./ha-selector-statistic"),
file: () => import("./ha-selector-file"),
label: () => import("./ha-selector-label"),
language: () => import("./ha-selector-language"),
navigation: () => import("./ha-selector-navigation"),
number: () => import("./ha-selector-number"),

View File

@ -31,6 +31,7 @@ import {
expandAreaTarget,
expandDeviceTarget,
expandFloorTarget,
expandLabelTarget,
Selector,
} from "../data/selector";
import { HomeAssistant, ValueChangedEvent } from "../types";
@ -274,6 +275,24 @@ export class HaServiceControl extends LitElement {
const targetFloors = ensureArray(
value?.target?.floor_id || value?.data?.floor_id
)?.slice();
const targetLabels = ensureArray(
value?.target?.label_id || value?.data?.label_id
)?.slice();
if (targetLabels) {
targetLabels.forEach((labelId) => {
const expanded = expandLabelTarget(
this.hass,
labelId,
this.hass.areas,
this.hass.devices,
this.hass.entities,
targetSelector
);
targetDevices.push(...expanded.devices);
targetEntities.push(...expanded.entities);
targetAreas.push(...expanded.areas);
});
}
if (targetFloors) {
targetFloors.forEach((floorId) => {
const expanded = expandFloorTarget(

View File

@ -7,6 +7,7 @@ import {
mdiClose,
mdiDevices,
mdiFloorPlan,
mdiLabel,
mdiPlus,
mdiSofa,
mdiUnfoldMoreVertical,
@ -45,7 +46,13 @@ import {
FloorRegistryEntry,
subscribeFloorRegistry,
} from "../data/floor_registry";
import {
LabelRegistryEntry,
subscribeLabelRegistry,
} from "../data/label_registry";
import { computeCssColor } from "../common/color/compute-color";
import { AreaRegistryEntry } from "../data/area_registry";
import { hex2rgb } from "../common/color/convert-color";
@customElement("ha-target-picker")
export class HaTargetPicker extends SubscribeMixin(LitElement) {
@ -83,7 +90,11 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ type: Boolean }) public addOnTop = false;
@state() private _addMode?: "area_id" | "entity_id" | "device_id";
@state() private _addMode?:
| "area_id"
| "entity_id"
| "device_id"
| "label_id";
@query("#input") private _inputElement?;
@ -91,6 +102,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@state() private _floors?: FloorRegistryEntry[];
@state() private _labels?: LabelRegistryEntry[];
private _opened = false;
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
@ -98,6 +111,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
subscribeFloorRegistry(this.hass.connection, (floors) => {
this._floors = floors;
}),
subscribeLabelRegistry(this.hass.connection, (labels) => {
this._labels = labels;
}),
];
}
@ -138,7 +154,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
mdiSofa
);
})
: ""}
: nothing}
${this.value?.device_id
? ensureArray(this.value.device_id).map((device_id) => {
const device = this.hass.devices![device_id];
@ -151,7 +167,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
mdiDevices
);
})
: ""}
: nothing}
${this.value?.entity_id
? ensureArray(this.value.entity_id).map((entity_id) => {
const entity = this.hass.states[entity_id];
@ -162,7 +178,35 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
entity
);
})
: ""}
: nothing}
${this.value?.label_id
? ensureArray(this.value.label_id).map((label_id) => {
const label = this._labels?.find(
(lbl) => lbl.label_id === label_id
);
let color = label?.color
? computeCssColor(label.color)
: undefined;
if (color?.startsWith("var(")) {
const computedStyles = getComputedStyle(this);
color = computedStyles.getPropertyValue(
color.substring(4, color.length - 1)
);
}
if (color?.startsWith("#")) {
color = hex2rgb(color).join(",");
}
return this._renderChip(
"label_id",
label_id,
label ? label.name : label_id,
undefined,
label?.icon,
mdiLabel,
color
);
})
: nothing}
</div>
`;
}
@ -230,6 +274,26 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
</span>
</span>
</div>
<div
class="mdc-chip label_id add"
.type=${"label_id"}
@click=${this._showPicker}
>
<div class="mdc-chip__ripple"></div>
<ha-svg-icon
class="mdc-chip__icon mdc-chip__icon--leading"
.path=${mdiPlus}
></ha-svg-icon>
<span role="gridcell">
<span role="button" tabindex="0" class="mdc-chip__primary-action">
<span class="mdc-chip__text"
>${this.hass.localize(
"ui.components.target-picker.add_label_id"
)}</span
>
</span>
</span>
</div>
${this._renderPicker()}
</div>
${this.helper
@ -243,18 +307,22 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
private _renderChip(
type: "floor_id" | "area_id" | "device_id" | "entity_id",
type: "floor_id" | "area_id" | "device_id" | "entity_id" | "label_id",
id: string,
name: string,
entityState?: HassEntity,
icon?: string | null,
fallbackIconPath?: string
fallbackIconPath?: string,
color?: string
) {
return html`
<div
class="mdc-chip ${classMap({
[type]: true,
})}"
style=${color
? `--color: rgb(${color}); --background-color: rgba(${color}, .3)`
: ""}
>
${icon
? html`<ha-icon
@ -368,23 +436,42 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@click=${this._preventDefault}
></ha-device-picker>
`
: html`
<ha-entity-picker
.hass=${this.hass}
id="input"
.type=${"entity_id"}
.label=${this.hass.localize(
"ui.components.target-picker.add_entity_id"
)}
.entityFilter=${this.entityFilter}
.includeDeviceClasses=${this.includeDeviceClasses}
.includeDomains=${this.includeDomains}
.excludeEntities=${ensureArray(this.value?.entity_id)}
@value-changed=${this._targetPicked}
@click=${this._preventDefault}
allow-custom-entity
></ha-entity-picker>
`}</mwc-menu-surface
: this._addMode === "label_id"
? html`
<ha-label-picker
.hass=${this.hass}
id="input"
.type=${"label_id"}
.label=${this.hass.localize(
"ui.components.target-picker.add_label_id"
)}
no-add
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDeviceClasses=${this.includeDeviceClasses}
.includeDomains=${this.includeDomains}
.excludeLabels=${ensureArray(this.value?.label_id)}
@value-changed=${this._targetPicked}
@click=${this._preventDefault}
></ha-label-picker>
`
: html`
<ha-entity-picker
.hass=${this.hass}
id="input"
.type=${"entity_id"}
.label=${this.hass.localize(
"ui.components.target-picker.add_entity_id"
)}
.entityFilter=${this.entityFilter}
.includeDeviceClasses=${this.includeDeviceClasses}
.includeDomains=${this.includeDomains}
.excludeEntities=${ensureArray(this.value?.entity_id)}
@value-changed=${this._targetPicked}
@click=${this._preventDefault}
allow-custom-entity
></ha-entity-picker>
`}</mwc-menu-surface
>`;
}
@ -471,6 +558,34 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
newEntities.push(entity.entity_id);
}
});
} else if (target.type === "label_id") {
Object.values(this.hass.areas).forEach((area) => {
if (
area.labels.includes(target.id) &&
!this.value!.area_id?.includes(area.area_id) &&
this._areaMeetsFilter(area)
) {
newAreas.push(area.area_id);
}
});
Object.values(this.hass.devices).forEach((device) => {
if (
device.labels.includes(target.id) &&
!this.value!.device_id?.includes(device.id) &&
this._deviceMeetsFilter(device)
) {
newDevices.push(device.id);
}
});
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.labels.includes(target.id) &&
!this.value!.entity_id?.includes(entity.entity_id) &&
this._entityRegMeetsFilter(entity)
) {
newEntities.push(entity.entity_id);
}
});
} else {
return;
}
@ -578,39 +693,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
(entity) => entity.device_id === device.id
);
if (this.includeDomains) {
if (!devEntities || !devEntities.length) {
return false;
}
if (
!devEntities.some((entity) =>
this.includeDomains!.includes(computeDomain(entity.entity_id))
)
) {
return false;
}
}
if (this.includeDeviceClasses) {
if (!devEntities || !devEntities.length) {
return false;
}
if (
!devEntities.some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
this.includeDeviceClasses!.includes(
stateObj.attributes.device_class
)
);
})
) {
return false;
}
if (!devEntities.some((entity) => this._entityRegMeetsFilter(entity))) {
return false;
}
if (this.deviceFilter) {
@ -619,19 +703,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
}
if (this.entityFilter) {
if (
!devEntities.some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return this.entityFilter!(stateObj);
})
) {
return false;
}
}
return true;
}
@ -719,8 +790,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
--mdc-icon-size: 20px;
border-radius: 50%;
padding: 6px;
margin-left: -14px !important;
margin-inline-start: -14px !important;
margin-left: -13px !important;
margin-inline-start: -13px !important;
margin-inline-end: 4px !important;
direction: var(--direction);
}
@ -731,7 +802,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
.mdc-chip.area_id:not(.add),
.mdc-chip.floor_id:not(.add) {
border: 2px solid #fed6a4;
border: 1px solid #fed6a4;
background: var(--card-background-color);
}
.mdc-chip.area_id:not(.add) .mdc-chip__icon--leading,
@ -741,7 +812,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
background: #fed6a4;
}
.mdc-chip.device_id:not(.add) {
border: 2px solid #a8e1fb;
border: 1px solid #a8e1fb;
background: var(--card-background-color);
}
.mdc-chip.device_id:not(.add) .mdc-chip__icon--leading,
@ -749,13 +820,21 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
background: #a8e1fb;
}
.mdc-chip.entity_id:not(.add) {
border: 2px solid #d2e7b9;
border: 1px solid #d2e7b9;
background: var(--card-background-color);
}
.mdc-chip.entity_id:not(.add) .mdc-chip__icon--leading,
.mdc-chip.entity_id.add {
background: #d2e7b9;
}
.mdc-chip.label_id:not(.add) {
border: 1px solid var(--color, #e0e0e0);
background: var(--card-background-color);
}
.mdc-chip.label_id:not(.add) .mdc-chip__icon--leading,
.mdc-chip.label_id.add {
background: var(--background-color, #e0e0e0);
}
.mdc-chip:hover {
z-index: 5;
}

View File

@ -11,6 +11,7 @@ export interface AreaRegistryEntry {
name: string;
picture: string | null;
icon: string | null;
labels: string[];
aliases: string[];
}
@ -28,6 +29,7 @@ export interface AreaRegistryEntryMutableParams {
picture?: string | null;
icon?: string | null;
aliases?: string[];
labels?: string[];
}
export const createAreaRegistryEntry = (

View File

@ -20,6 +20,7 @@ export interface DeviceRegistryEntry {
manufacturer: string | null;
model: string | null;
name: string | null;
labels: string[];
sw_version: string | null;
hw_version: string | null;
serial_number: string | null;
@ -43,6 +44,7 @@ export interface DeviceRegistryEntryMutableParams {
area_id?: string | null;
name_by_user?: string | null;
disabled_by?: string | null;
labels?: string[];
}
export const fallbackDeviceName = (
@ -140,7 +142,7 @@ export const getDeviceEntityDisplayLookup = (
export const getDeviceIntegrationLookup = (
entitySources: EntitySources,
entities: EntityRegistryDisplayEntry[]
entities: EntityRegistryDisplayEntry[] | EntityRegistryEntry[]
): Record<string, string[]> => {
const deviceIntegrations: Record<string, string[]> = {};

View File

@ -18,6 +18,7 @@ export interface EntityRegistryDisplayEntry {
icon?: string;
device_id?: string;
area_id?: string;
labels: string[];
hidden?: boolean;
entity_category?: entityCategory;
translation_key?: string;
@ -30,6 +31,7 @@ export interface EntityRegistryDisplayEntryResponse {
ei: string;
di?: string;
ai?: string;
lb: string[];
ec?: number;
en?: string;
ic?: string;
@ -50,6 +52,7 @@ export interface EntityRegistryEntry {
config_entry_id: string | null;
device_id: string | null;
area_id: string | null;
labels: string[];
disabled_by: "user" | "device" | "integration" | "config_entry" | null;
hidden_by: Exclude<EntityRegistryEntry["disabled_by"], "config_entry">;
entity_category: entityCategory | null;
@ -133,6 +136,7 @@ export interface EntityRegistryEntryUpdateParams {
| WeatherEntityOptions
| LightEntityOptions;
aliases?: string[];
labels?: string[];
}
const batteryPriorities = ["sensor", "binary_sensor"];

View File

@ -0,0 +1,86 @@
import { Connection, createCollection } from "home-assistant-js-websocket";
import { Store } from "home-assistant-js-websocket/dist/store";
import { stringCompare } from "../common/string/compare";
import { HomeAssistant } from "../types";
import { debounce } from "../common/util/debounce";
export interface LabelRegistryEntry {
label_id: string;
name: string;
icon: string | null;
color: string | null;
}
export interface LabelRegistryEntryMutableParams {
name: string;
icon?: string | null;
color?: string | null;
}
export const fetchLabelRegistry = (conn: Connection) =>
conn
.sendMessagePromise({
type: "config/label_registry/list",
})
.then((labels) =>
(labels as LabelRegistryEntry[]).sort((ent1, ent2) =>
stringCompare(ent1.name, ent2.name)
)
);
export const subscribeLabelRegistryUpdates = (
conn: Connection,
store: Store<LabelRegistryEntry[]>
) =>
conn.subscribeEvents(
debounce(
() =>
fetchLabelRegistry(conn).then((labels: LabelRegistryEntry[]) =>
store.setState(labels, true)
),
500,
true
),
"label_registry_updated"
);
export const subscribeLabelRegistry = (
conn: Connection,
onChange: (labels: LabelRegistryEntry[]) => void
) =>
createCollection<LabelRegistryEntry[]>(
"_labelRegistry",
fetchLabelRegistry,
subscribeLabelRegistryUpdates,
conn,
onChange
);
export const createLabelRegistryEntry = (
hass: HomeAssistant,
values: LabelRegistryEntryMutableParams
) =>
hass.callWS<LabelRegistryEntry>({
type: "config/label_registry/create",
...values,
});
export const updateLabelRegistryEntry = (
hass: HomeAssistant,
labelId: string,
updates: Partial<LabelRegistryEntryMutableParams>
) =>
hass.callWS<LabelRegistryEntry>({
type: "config/label_registry/update",
label_id: labelId,
...updates,
});
export const deleteLabelRegistryEntry = (
hass: HomeAssistant,
labelId: string
) =>
hass.callWS({
type: "config/label_registry/delete",
label_id: labelId,
});

View File

@ -8,7 +8,10 @@ import {
DeviceRegistryEntry,
getDeviceIntegrationLookup,
} from "./device_registry";
import { EntityRegistryDisplayEntry } from "./entity_registry";
import {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "./entity_registry";
import { EntitySources } from "./entity_sources";
export type Selector =
@ -34,6 +37,7 @@ export type Selector =
| LegacyEntitySelector
| FileSelector
| IconSelector
| LabelSelector
| LanguageSelector
| LocationSelector
| MediaSelector
@ -242,6 +246,12 @@ export interface IconSelector {
} | null;
}
export interface LabelSelector {
label: {
multiple?: boolean;
};
}
export interface LanguageSelector {
language: {
languages?: string[];
@ -421,9 +431,69 @@ export interface UiActionSelector {
export interface UiColorSelector {
// eslint-disable-next-line @typescript-eslint/ban-types
ui_color: {} | null;
ui_color: { default_color?: boolean } | null;
}
export const expandLabelTarget = (
hass: HomeAssistant,
labelId: string,
areas: HomeAssistant["areas"],
devices: HomeAssistant["devices"],
entities: HomeAssistant["entities"],
targetSelector: TargetSelector,
entitySources?: EntitySources
) => {
const newEntities: string[] = [];
const newDevices: string[] = [];
const newAreas: string[] = [];
Object.values(areas).forEach((area) => {
if (
area.labels.includes(labelId) &&
areaMeetsTargetSelector(
hass,
entities,
devices,
area.area_id,
targetSelector,
entitySources
)
) {
newAreas.push(area.area_id);
}
});
Object.values(devices).forEach((device) => {
if (
device.labels.includes(labelId) &&
deviceMeetsTargetSelector(
hass,
Object.values(entities),
device,
targetSelector,
entitySources
)
) {
newDevices.push(device.id);
}
});
Object.values(entities).forEach((entity) => {
if (
entity.labels.includes(labelId) &&
entityMeetsTargetSelector(
hass.states[entity.entity_id],
targetSelector,
entitySources
)
) {
newEntities.push(entity.entity_id);
}
});
return { areas: newAreas, devices: newDevices, entities: newEntities };
};
export const expandFloorTarget = (
hass: HomeAssistant,
floorId: string,
@ -555,7 +625,7 @@ export const areaMeetsTargetSelector = (
export const deviceMeetsTargetSelector = (
hass: HomeAssistant,
entityRegistry: EntityRegistryDisplayEntry[],
entityRegistry: EntityRegistryDisplayEntry[] | EntityRegistryEntry[],
device: DeviceRegistryEntry,
targetSelector: TargetSelector,
entitySources?: EntitySources

View File

@ -12,6 +12,7 @@ import "../../../components/ha-settings-row";
import "../../../components/ha-icon-picker";
import "../../../components/ha-floor-picker";
import "../../../components/ha-textfield";
import "../../../components/ha-labels-picker";
import { AreaRegistryEntryMutableParams } from "../../../data/area_registry";
import { CropOptions } from "../../../dialogs/image-cropper-dialog/show-image-cropper-dialog";
import { haStyleDialog } from "../../../resources/styles";
@ -32,6 +33,8 @@ class DialogAreaDetail extends LitElement {
@state() private _aliases!: string[];
@state() private _labels!: string[];
@state() private _picture!: string | null;
@state() private _icon!: string | null;
@ -51,6 +54,7 @@ class DialogAreaDetail extends LitElement {
this._error = undefined;
this._name = this._params.entry ? this._params.entry.name : "";
this._aliases = this._params.entry ? this._params.entry.aliases : [];
this._labels = this._params.entry ? this._params.entry.labels : [];
this._picture = this._params.entry?.picture || null;
this._icon = this._params.entry?.icon || null;
this._floor = this._params.entry?.floor_id || null;
@ -123,6 +127,12 @@ class DialogAreaDetail extends LitElement {
.label=${this.hass.localize("ui.panel.config.areas.editor.floor")}
></ha-floor-picker>
<ha-labels-picker
.hass=${this.hass}
.value=${this._labels}
@value-changed=${this._labelsChanged}
></ha-labels-picker>
<ha-picture-upload
.hass=${this.hass}
.value=${this._picture}
@ -184,6 +194,11 @@ class DialogAreaDetail extends LitElement {
this._icon = ev.detail.value;
}
private _labelsChanged(ev) {
this._error = undefined;
this._labels = ev.detail.value;
}
private _pictureChanged(ev: ValueChangedEvent<string | null>) {
this._error = undefined;
this._picture = (ev.target as HaPictureUpload).value;
@ -198,6 +213,7 @@ class DialogAreaDetail extends LitElement {
picture: this._picture || (create ? undefined : null),
icon: this._icon || (create ? undefined : null),
floor_id: this._floor || (create ? undefined : null),
labels: this._labels || null,
aliases: this._aliases,
};
if (create) {
@ -226,6 +242,7 @@ class DialogAreaDetail extends LitElement {
ha-textfield,
ha-icon-picker,
ha-floor-picker,
ha-labels-picker,
ha-picture-upload {
display: block;
margin-bottom: 16px;

View File

@ -5,6 +5,7 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert";
import "../../../../components/ha-area-picker";
import "../../../../components/ha-dialog";
import "../../../../components/ha-labels-picker";
import type { HaSwitch } from "../../../../components/ha-switch";
import "../../../../components/ha-textfield";
import {
@ -27,6 +28,8 @@ class DialogDeviceRegistryDetail extends LitElement {
@state() private _areaId!: string;
@state() private _labels!: string[];
@state() private _disabledBy!: DeviceRegistryEntry["disabled_by"];
@state() private _submitting = false;
@ -38,6 +41,7 @@ class DialogDeviceRegistryDetail extends LitElement {
this._error = undefined;
this._nameByUser = this._params.device.name_by_user || "";
this._areaId = this._params.device.area_id || "";
this._labels = this._params.device.labels || [];
this._disabledBy = this._params.device.disabled_by;
await this.updateComplete;
}
@ -79,6 +83,11 @@ class DialogDeviceRegistryDetail extends LitElement {
.value=${this._areaId}
@value-changed=${this._areaPicked}
></ha-area-picker>
<ha-labels-picker
.hass=${this.hass}
.value=${this._labels}
@value-changed=${this._labelsChanged}
></ha-labels-picker>
<div class="row">
<ha-switch
.checked=${!this._disabledBy}
@ -150,6 +159,10 @@ class DialogDeviceRegistryDetail extends LitElement {
this._areaId = event.detail.value;
}
private _labelsChanged(event: CustomEvent): void {
this._labels = event.detail.value;
}
private _disabledByChanged(ev: Event): void {
this._disabledBy = (ev.target as HaSwitch).checked ? null : "user";
}
@ -160,6 +173,7 @@ class DialogDeviceRegistryDetail extends LitElement {
await this._params!.updateEntry({
name_by_user: this._nameByUser.trim() || null,
area_id: this._areaId || null,
labels: this._labels || null,
disabled_by: this._disabledBy || null,
});
this.closeDialog();
@ -182,7 +196,9 @@ class DialogDeviceRegistryDetail extends LitElement {
margin-inline-end: auto;
margin-inline-start: initial;
}
ha-textfield {
ha-textfield,
ha-labels-picker,
ha-area-picker {
display: block;
margin-bottom: 16px;
}

View File

@ -37,6 +37,7 @@ import "../../../components/ha-select";
import "../../../components/ha-settings-row";
import "../../../components/ha-state-icon";
import "../../../components/ha-switch";
import "../../../components/ha-labels-picker";
import type { HaSwitch } from "../../../components/ha-switch";
import "../../../components/ha-textfield";
import {
@ -162,6 +163,8 @@ export class EntityRegistrySettingsEditor extends LitElement {
@state() private _areaId?: string | null;
@state() private _labels?: string[] | null;
@state() private _disabledBy!: EntityRegistryEntry["disabled_by"];
@state() private _hiddenBy!: EntityRegistryEntry["hidden_by"];
@ -215,6 +218,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
this.entry.device_class || this.entry.original_device_class;
this._origEntityId = this.entry.entity_id;
this._areaId = this.entry.area_id;
this._labels = this.entry.labels;
this._entityId = this.entry.entity_id;
this._disabledBy = this.entry.disabled_by;
this._hiddenBy = this.entry.hidden_by;
@ -759,6 +763,12 @@ export class EntityRegistrySettingsEditor extends LitElement {
@value-changed=${this._areaPicked}
></ha-area-picker>`
: ""}
<ha-labels-picker
.hass=${this.hass}
.value=${this._labels}
.disabled=${this.disabled}
@value-changed=${this._labelsChanged}
></ha-labels-picker>
${this._cameraPrefs
? html`
<ha-settings-row>
@ -1008,6 +1018,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
name: this._name.trim() || null,
icon: this._icon.trim() || null,
area_id: this._areaId || null,
labels: this._labels || [],
new_entity_id: this._entityId.trim(),
};
@ -1350,6 +1361,10 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._areaId = ev.detail.value;
}
private _labelsChanged(ev: CustomEvent) {
this._labels = ev.detail.value;
}
private async _fetchCameraPrefs() {
this._cameraPrefs = await fetchCameraPrefs(this.hass, this.entry.entity_id);
}

View File

@ -736,6 +736,7 @@ export class HaConfigEntities extends LitElement {
entity_category: null,
has_entity_name: false,
options: null,
labels: [],
});
}
if (changed) {

View File

@ -9,6 +9,7 @@ import {
mdiDevices,
mdiInformation,
mdiInformationOutline,
mdiLabel,
mdiLightningBolt,
mdiMapMarkerRadius,
mdiMathLog,
@ -267,6 +268,14 @@ export const configSections: { [name: string]: PageNavigation[] } = {
iconColor: "#2D338F",
core: true,
},
{
component: "labels",
path: "/config/labels",
translationKey: "ui.panel.config.labels.caption",
iconPath: mdiLabel,
iconColor: "#2D338F",
core: true,
},
{
component: "zone",
path: "/config/zone",
@ -451,6 +460,10 @@ class HaPanelConfig extends SubscribeMixin(HassRouterPage) {
tag: "ha-config-integrations",
load: () => import("./integrations/ha-config-integrations"),
},
labels: {
tag: "ha-config-labels",
load: () => import("./labels/ha-config-labels"),
},
lovelace: {
tag: "ha-config-lovelace",
load: () => import("./lovelace/ha-config-lovelace"),

View File

@ -0,0 +1,214 @@
import "@material/mwc-button";
import { CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-formfield";
import "../../../components/ha-switch";
import "../../../components/ha-textfield";
import "../../../components/ha-icon-picker";
import "../../../components/ha-color-picker";
import { HassDialog } from "../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import { LabelDetailDialogParams } from "./show-dialog-label-detail";
import {
LabelRegistryEntry,
LabelRegistryEntryMutableParams,
} from "../../../data/label_registry";
@customElement("dialog-label-detail")
class DialogLabelDetail
extends LitElement
implements HassDialog<LabelDetailDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _name!: string;
@state() private _icon!: string;
@state() private _color!: string;
@state() private _error?: string;
@state() private _params?: LabelDetailDialogParams;
@state() private _submitting = false;
public showDialog(params: LabelDetailDialogParams): void {
this._params = params;
this._error = undefined;
if (this._params.entry) {
this._name = this._params.entry.name || "";
this._icon = this._params.entry.icon || "";
this._color = this._params.entry.color || "";
} else {
this._name = this._params.suggestedName || "";
this._icon = "";
this._color = "";
}
}
public closeDialog(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-dialog
open
@closed=${this.closeDialog}
scrimClickAction
escapeKeyAction
.heading=${createCloseHeading(
this.hass,
this._params.entry
? this._params.entry.name || this._params.entry.label_id
: this.hass!.localize("ui.panel.config.labels.detail.new_label")
)}
>
<div>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<div class="form">
<ha-textfield
dialogInitialFocus
.value=${this._name}
.configValue=${"name"}
@input=${this._input}
.label=${this.hass!.localize(
"ui.panel.config.labels.detail.name"
)}
.validationMessage=${this.hass!.localize(
"ui.panel.config.labels.detail.required_error_msg"
)}
required
></ha-textfield>
<ha-icon-picker
.value=${this._icon}
.hass=${this.hass}
.configValue=${"icon"}
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.panel.config.labels.detail.icon"
)}
></ha-icon-picker>
<ha-color-picker
.value=${this._color}
.configValue=${"color"}
.hass=${this.hass}
@value-changed=${this._valueChanged}
.label=${this.hass!.localize(
"ui.panel.config.labels.detail.color"
)}
></ha-color-picker>
</div>
</div>
${this._params.entry && this._params.removeEntry
? html`
<mwc-button
slot="secondaryAction"
class="warning"
@click=${this._deleteEntry}
.disabled=${this._submitting}
>
${this.hass!.localize("ui.panel.config.labels.detail.delete")}
</mwc-button>
`
: nothing}
<mwc-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${this._submitting || !this._name}
>
${this._params.entry
? this.hass!.localize("ui.panel.config.labels.detail.update")
: this.hass!.localize("ui.panel.config.labels.detail.create")}
</mwc-button>
</ha-dialog>
`;
}
private _input(ev: Event) {
const target = ev.target as any;
const configValue = target.configValue;
this._error = undefined;
this[`_${configValue}`] = target.value;
}
private _valueChanged(ev: CustomEvent) {
const target = ev.target as any;
const configValue = target.configValue;
this._error = undefined;
this[`_${configValue}`] = ev.detail.value || "";
}
private async _updateEntry() {
this._submitting = true;
let newValue: LabelRegistryEntry | undefined;
try {
const values: LabelRegistryEntryMutableParams = {
name: this._name.trim(),
icon: this._icon.trim() || null,
color: this._color.trim() || null,
};
if (this._params!.entry) {
newValue = await this._params!.updateEntry!(values);
} else {
newValue = await this._params!.createEntry!(values);
}
this.closeDialog();
} catch (err: any) {
this._error = err ? err.message : "Unknown error";
} finally {
this._submitting = false;
}
return newValue;
}
private async _deleteEntry() {
this._submitting = true;
try {
if (await this._params!.removeEntry!()) {
this._params = undefined;
}
} finally {
this._submitting = false;
}
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
a {
color: var(--primary-color);
}
ha-textfield,
ha-icon-picker,
ha-color-picker {
display: block;
}
ha-color-picker {
margin-top: 16px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-label-detail": DialogLabelDetail;
}
}

View File

@ -0,0 +1,212 @@
import { mdiHelpCircle, mdiPlus } from "@mdi/js";
import { LitElement, PropertyValues, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color";
import { LocalizeFunc } from "../../../common/translations/localize";
import {
DataTableColumnContainer,
RowClickedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/ha-fab";
import "../../../components/ha-icon-button";
import "../../../components/ha-relative-time";
import {
LabelRegistryEntry,
LabelRegistryEntryMutableParams,
createLabelRegistryEntry,
deleteLabelRegistryEntry,
fetchLabelRegistry,
updateLabelRegistryEntry,
} from "../../../data/label_registry";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table";
import { HomeAssistant, Route } from "../../../types";
import { configSections } from "../ha-panel-config";
import { showLabelDetailDialog } from "./show-dialog-label-detail";
@customElement("ha-config-labels")
export class HaConfigLabels extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
@state() private _labels: LabelRegistryEntry[] = [];
private _columns = memoizeOne((localize: LocalizeFunc) => {
const columns: DataTableColumnContainer<LabelRegistryEntry> = {
icon: {
title: "",
label: localize("ui.panel.config.labels.headers.icon"),
type: "icon",
template: (label) =>
label.icon ? html`<ha-icon .icon=${label.icon}></ha-icon>` : nothing,
},
color: {
title: "",
label: localize("ui.panel.config.labels.headers.color"),
type: "icon",
template: (label) =>
label.color
? html`<div
style="
background-color: ${computeCssColor(label.color)};
border-radius: 10px;
width: 20px;
height: 20px;"
></div>`
: nothing,
},
name: {
title: localize("ui.panel.config.labels.headers.name"),
main: true,
sortable: true,
filterable: true,
grows: true,
},
};
return columns;
});
private _data = memoizeOne(
(labels: LabelRegistryEntry[]): LabelRegistryEntry[] =>
labels.map((label) => ({
...label,
}))
);
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
this._fetchLabels();
}
protected render() {
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${configSections.areas}
.columns=${this._columns(this.hass.localize)}
.data=${this._data(this._labels)}
.noDataText=${this.hass.localize("ui.panel.config.labels.no_labels")}
hasFab
@row-click=${this._editLabel}
clickable
id="label_id"
>
<ha-icon-button
slot="toolbar-icon"
@click=${this._showHelp}
.label=${this.hass.localize("ui.common.help")}
.path=${mdiHelpCircle}
></ha-icon-button>
<ha-fab
slot="fab"
.label=${this.hass.localize("ui.panel.config.labels.add_label")}
extended
@click=${this._addLabel}
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
</hass-tabs-subpage-data-table>
`;
}
private _editLabel(ev: CustomEvent<RowClickedEvent>) {
const label = this._labels.find((lbl) => lbl.label_id === ev.detail.id);
this._openDialog(label);
}
private _showHelp() {
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.labels.caption"),
text: html`
${this.hass.localize("ui.panel.config.labels.introduction")}
<p>${this.hass.localize("ui.panel.config.labels.introduction2")}</p>
`,
});
}
private async _fetchLabels() {
this._labels = await fetchLabelRegistry(this.hass.connection);
}
private _addLabel() {
this._openDialog();
}
private _openDialog(entry?: LabelRegistryEntry) {
showLabelDetailDialog(this, {
entry,
createEntry: (values) => this._createLabel(values),
updateEntry: entry
? (values) => this._updateLabel(entry, values)
: undefined,
removeEntry: entry ? () => this._removeLabel(entry) : undefined,
});
}
private async _createLabel(
values: LabelRegistryEntryMutableParams
): Promise<LabelRegistryEntry> {
const newTag = await createLabelRegistryEntry(this.hass, values);
this._labels = [...this._labels, newTag];
return newTag;
}
private async _updateLabel(
selectedLabel: LabelRegistryEntry,
values: Partial<LabelRegistryEntryMutableParams>
): Promise<LabelRegistryEntry> {
const updated = await updateLabelRegistryEntry(
this.hass,
selectedLabel.label_id,
values
);
this._labels = this._labels.map((label) =>
label.label_id === selectedLabel.label_id ? updated : label
);
return updated;
}
private async _removeLabel(selectedLabel: LabelRegistryEntry) {
if (
!(await showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.labels.confirm_remove_title"
),
text: this.hass.localize("ui.panel.config.labels.confirm_remove", {
label: selectedLabel.name || selectedLabel.label_id,
}),
dismissText: this.hass!.localize("ui.common.cancel"),
confirmText: this.hass!.localize("ui.common.remove"),
}))
) {
return false;
}
try {
await deleteLabelRegistryEntry(this.hass, selectedLabel.label_id);
this._labels = this._labels.filter(
(label) => label.label_id !== selectedLabel.label_id
);
return true;
} catch (err: any) {
return false;
}
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-labels": HaConfigLabels;
}
}

View File

@ -0,0 +1,31 @@
import { fireEvent } from "../../../common/dom/fire_event";
import {
LabelRegistryEntry,
LabelRegistryEntryMutableParams,
} from "../../../data/label_registry";
export interface LabelDetailDialogParams {
entry?: LabelRegistryEntry;
suggestedName?: string;
createEntry?: (
values: LabelRegistryEntryMutableParams,
labelId?: string
) => Promise<LabelRegistryEntry>;
updateEntry?: (
updates: Partial<LabelRegistryEntryMutableParams>
) => Promise<LabelRegistryEntry>;
removeEntry?: () => Promise<boolean>;
}
export const loadLabelDetailDialog = () => import("./dialog-label-detail");
export const showLabelDetailDialog = (
element: HTMLElement,
dialogParams: LabelDetailDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-label-detail",
dialogImport: loadLabelDetailDialog,
dialogParams,
});
};

View File

@ -155,7 +155,7 @@ export class HuiTileCardEditor
{
name: "color",
selector: {
ui_color: {},
ui_color: { default_color: true },
},
},
{

View File

@ -232,6 +232,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
entity_id: entity.ei,
device_id: entity.di,
area_id: entity.ai,
labels: entity.lb,
translation_key: entity.tk,
platform: entity.pl,
entity_category:

View File

@ -487,14 +487,17 @@
"expand_floor_id": "Split this floor into separate areas.",
"expand_area_id": "Split this area into separate devices and entities.",
"expand_device_id": "Split this device into separate entities.",
"expand_label_id": "Split this label into separate area, devices and entities.",
"remove": "Remove",
"remove_floor_id": "Remove floor",
"remove_area_id": "Remove area",
"remove_device_id": "Remove device",
"remove_entity_id": "Remove entity",
"remove_label_id": "Remove label",
"add_area_id": "Choose area",
"add_device_id": "Choose device",
"add_entity_id": "Choose entity"
"add_entity_id": "Choose entity",
"add_label_id": "Choose label"
},
"config-entry-picker": {
"config_entry": "Integration"
@ -544,6 +547,23 @@
"device": "Device",
"no_area": "No area"
},
"label-picker": {
"clear": "Clear",
"show_labels": "Show labels",
"labels": "Labels",
"add_label": "Add label",
"add_new_sugestion": "Add new label ''{name}''",
"add_new": "Add new label…",
"no_labels": "You don't have any labels",
"no_match": "No matching labels found",
"add_dialog": {
"title": "Add new label",
"text": "Enter the name of the new label.",
"name": "Name",
"add": "Add",
"failed_create_label": "Failed to create label."
}
},
"area-picker": {
"clear": "Clear",
"show_areas": "Show areas",
@ -585,6 +605,16 @@
"show": "Show {area}",
"hide": "Hide {area}"
},
"label-picker": {
"clear": "Clear",
"show_labels": "Show labels",
"label": "Label",
"add_new_sugestion": "Add new label ''{name}''",
"add_new": "Add new label…",
"add_label": "Add label",
"no_labels": "You don't have any labels",
"no_match": "No matching labels found"
},
"statistic-picker": {
"statistic": "Statistic",
"no_statistics": "You don't have any statistics",
@ -634,6 +664,38 @@
"supported_formats": "Supports JPEG, PNG, or GIF image.",
"unsupported_format": "Unsupported format, please choose a JPEG, PNG, or GIF image."
},
"color-picker": {
"default_color": "Default color (state)",
"colors": {
"primary": "Primary",
"accent": "Accent",
"disabled": "Disabled",
"inactive": "Inactive",
"red": "Red",
"pink": "Pink",
"purple": "Purple",
"deep-purple": "Deep purple",
"indigo": "Indigo",
"blue": "Blue",
"light-blue": "Light blue",
"cyan": "Cyan",
"teal": "Teal",
"green": "Green",
"light-green": "Light Green",
"lime": "Lime",
"yellow": "Yellow",
"amber": "Amber",
"orange": "Orange",
"deep-orange": "Deep orange",
"brown": "Brown",
"light-grey": "Light grey",
"grey": "Grey",
"dark-grey": "Dark grey",
"blue-grey": "Blue grey",
"black": "Black",
"white": "White"
}
},
"date-range-picker": {
"start_date": "Start date",
"end_date": "End date",
@ -1709,7 +1771,7 @@
"secondary": "Manage who can access your home"
},
"areas": {
"main": "Areas & zones",
"main": "Areas, labels & zones",
"secondary": "Manage locations in and around your house"
},
"companion": {
@ -1796,6 +1858,28 @@
"aliases_description": "Aliases are alternative names used in voice assistants to refer to this floor."
}
},
"labels": {
"caption": "Labels",
"description": "Group devices and entities",
"headers": { "name": "Name", "icon": "Icon", "color": "Color" },
"add_label": "Add label",
"no_labels": "You don't have any labels",
"introduction": "Labels can help you organize your areas, devices and entities. They can be used to filter in the UI, or use them as a target in automations.",
"introduction2": "Go to the area, device or entity you want to add a label to, and click on the edit button to assign labels to them.",
"confirm_remove_title": "Remove label?",
"confirm_remove": "Are you sure you want to remove label {label}? It will be removed from all areas, devices and entities.",
"detail": {
"new_label": "New label",
"name": "Name",
"icon": "Icon",
"color": "Color",
"description": "Description",
"delete": "Delete",
"update": "Update",
"create": "Create",
"required_error_msg": "[%key:ui::panel::config::zone::detail::required_error_msg%]"
}
},
"areas": {
"caption": "Areas",
"description": "Group devices and entities into areas",
@ -5841,38 +5925,6 @@
"warning_multiple_cards": "This view contains more than one card, but a panel view can only show 1 card."
}
},
"color-picker": {
"default_color": "Default color (state)",
"colors": {
"primary": "Primary",
"accent": "Accent",
"disabled": "Disabled",
"inactive": "Inactive",
"red": "Red",
"pink": "Pink",
"purple": "Purple",
"deep-purple": "Deep purple",
"indigo": "Indigo",
"blue": "Blue",
"light-blue": "Light blue",
"cyan": "Cyan",
"teal": "Teal",
"green": "Green",
"light-green": "Light Green",
"lime": "Lime",
"yellow": "Yellow",
"amber": "Amber",
"orange": "Orange",
"deep-orange": "Deep orange",
"brown": "Brown",
"light-grey": "Light grey",
"grey": "Grey",
"dark-grey": "Dark grey",
"blue-grey": "Blue grey",
"black": "Black",
"white": "White"
}
},
"cardpicker": {
"no_description": "No description available.",
"custom_card": "Custom",