From fb634a20929f8ad799e40f89ad90e8c8595ede54 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 25 May 2023 19:11:43 +0200 Subject: [PATCH 1/5] Add label registry --- src/data/label_registry.ts | 134 +++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 src/data/label_registry.ts diff --git a/src/data/label_registry.ts b/src/data/label_registry.ts new file mode 100644 index 000000000000..dc58a1caf365 --- /dev/null +++ b/src/data/label_registry.ts @@ -0,0 +1,134 @@ +import { Connection, createCollection } from "home-assistant-js-websocket"; +import { Store } from "home-assistant-js-websocket/dist/store"; +import { stringCompare } from "../common/string/compare"; +import { debounce } from "../common/util/debounce"; +import { HomeAssistant } from "../types"; +import { DeviceRegistryEntry } from "./device_registry"; +import { EntityRegistryEntry } from "./entity_registry"; + +export interface LabelRegistryEntry { + label_id: string; + name: string; + color: string | null; + description: string | null; + icon: string | null; +} + +export interface LabelEntityLookup { + [labelId: string]: EntityRegistryEntry[]; +} + +export interface LabelDeviceLookup { + [labelId: string]: DeviceRegistryEntry[]; +} + +export interface LabelRegistryEntryMutableParams { + name: string; + color?: string | null; + description?: string | null; + icon?: string | null; +} + +export const createLabelRegistryEntry = ( + hass: HomeAssistant, + values: LabelRegistryEntryMutableParams +) => + hass.callWS({ + type: "config/label_registry/create", + ...values, + }); + +export const updateLabelRegistryEntry = ( + hass: HomeAssistant, + labelId: string, + updates: Partial +) => + hass.callWS({ + 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, + }); + +const fetchLabelRegistry = (conn: Connection) => + conn + .sendMessagePromise({ + type: "config/label_registry/list", + }) + .then((labels) => + (labels as LabelRegistryEntry[]).sort((ent1, ent2) => + stringCompare(ent1.name, ent2.name) + ) + ); + +const subscribeLabelRegistryUpdates = ( + conn: Connection, + store: Store +) => + 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( + "_labelRegistry", + fetchLabelRegistry, + subscribeLabelRegistryUpdates, + conn, + onChange + ); + +export const getLabelEntityLookup = ( + entities: EntityRegistryEntry[] +): LabelEntityLookup => { + const labelEntityLookup: LabelEntityLookup = {}; + for (const entity of entities) { + if (!entity.labels) { + continue; + } + for (const label of entity.labels) { + if (!(label in labelEntityLookup)) { + labelEntityLookup[label] = []; + } + labelEntityLookup[label].push(entity); + } + } + return labelEntityLookup; +}; + +export const getLabelDeviceLookup = ( + devices: DeviceRegistryEntry[] +): LabelDeviceLookup => { + const labelDeviceLookup: LabelDeviceLookup = {}; + for (const device of devices) { + if (!device.labels) { + continue; + } + for (const label of device.labels) { + if (!(label in labelDeviceLookup)) { + labelDeviceLookup[label] = []; + } + labelDeviceLookup[label].push(device); + } + } + return labelDeviceLookup; +}; From a6abc880074e33d868109d1f682837c57cf69ed6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 25 May 2023 19:15:34 +0200 Subject: [PATCH 2/5] More foundations --- src/data/context.ts | 1 + src/data/device_registry.ts | 1 + src/data/entity_registry.ts | 3 +++ src/state/connection-mixin.ts | 10 ++++++++++ src/state/context-mixin.ts | 5 +++++ src/types.ts | 2 ++ 6 files changed, 22 insertions(+) diff --git a/src/data/context.ts b/src/data/context.ts index 7fbff9fd06a6..51f1e80c279b 100644 --- a/src/data/context.ts +++ b/src/data/context.ts @@ -9,6 +9,7 @@ export const entitiesContext = export const devicesContext = createContext("devices"); export const areasContext = createContext("areas"); +export const labelsContext = createContext("labels"); export const localizeContext = createContext("localize"); export const localeContext = createContext("locale"); diff --git a/src/data/device_registry.ts b/src/data/device_registry.ts index 6995e4b92443..7f7153d4267e 100644 --- a/src/data/device_registry.ts +++ b/src/data/device_registry.ts @@ -27,6 +27,7 @@ export interface DeviceRegistryEntry { entry_type: "service" | null; disabled_by: "user" | "integration" | "config_entry" | null; configuration_url: string | null; + labels: string[]; } export interface DeviceEntityDisplayLookup { diff --git a/src/data/entity_registry.ts b/src/data/entity_registry.ts index c55a09aaa33b..ab321591cf1f 100644 --- a/src/data/entity_registry.ts +++ b/src/data/entity_registry.ts @@ -20,6 +20,7 @@ export interface EntityRegistryDisplayEntry { translation_key?: string; platform?: string; display_precision?: number; + labels?: string[]; } interface EntityRegistryDisplayEntryResponse { @@ -33,6 +34,7 @@ interface EntityRegistryDisplayEntryResponse { tk?: string; hb?: boolean; dp?: number; + lb?: string[]; }[]; entity_categories: Record; } @@ -54,6 +56,7 @@ export interface EntityRegistryEntry { unique_id: string; translation_key?: string; options: EntityRegistryOptions | null; + labels: string[]; } export interface ExtEntityRegistryEntry extends EntityRegistryEntry { diff --git a/src/state/connection-mixin.ts b/src/state/connection-mixin.ts index c3cced5705c8..9d4de317d70e 100644 --- a/src/state/connection-mixin.ts +++ b/src/state/connection-mixin.ts @@ -14,6 +14,7 @@ import { subscribeAreaRegistry } from "../data/area_registry"; import { broadcastConnectionStatus } from "../data/connection-status"; import { subscribeDeviceRegistry } from "../data/device_registry"; import { subscribeEntityRegistryDisplay } from "../data/entity_registry"; +import { subscribeLabelRegistry } from "../data/label_registry"; import { subscribeFrontendUserData } from "../data/frontend"; import { forwardHaptic } from "../data/haptics"; import { DEFAULT_PANEL } from "../data/panel"; @@ -49,6 +50,7 @@ export const connectionMixin = >( entities: null as any, devices: null as any, areas: null as any, + labels: null as any, config: null as any, themes: null as any, selectedTheme: null, @@ -229,6 +231,7 @@ export const connectionMixin = >( name: entity.en, hidden: entity.hb, display_precision: entity.dp, + labels: entity.lb, }; } this._updateHass({ entities }); @@ -247,6 +250,13 @@ export const connectionMixin = >( } this._updateHass({ areas }); }); + subscribeLabelRegistry(conn, (labelReg) => { + const labels: HomeAssistant["labels"] = {}; + for (const label of labelReg) { + labels[label.label_id] = label; + } + this._updateHass({ labels }); + }); subscribeConfig(conn, (config) => { if (this.hass?.config?.time_zone !== config.time_zone) { import("../resources/intl-polyfill").then(() => { diff --git a/src/state/context-mixin.ts b/src/state/context-mixin.ts index d3bbc3ead621..44368e35c5dd 100644 --- a/src/state/context-mixin.ts +++ b/src/state/context-mixin.ts @@ -4,6 +4,7 @@ import { configContext, devicesContext, entitiesContext, + labelsContext, localeContext, localizeContext, panelsContext, @@ -42,6 +43,10 @@ export const contextMixin = >( context: areasContext, initialValue: this.hass ? this.hass.areas : this._pendingHass.areas, }), + labels: new ContextProvider(this, { + context: labelsContext, + initialValue: this.hass ? this.hass.labels : this._pendingHass.labels, + }), localize: new ContextProvider(this, { context: localizeContext, initialValue: this.hass diff --git a/src/types.ts b/src/types.ts index 8d0d37ee865b..111fc0809ad0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,7 @@ import { LocalizeFunc } from "./common/translations/localize"; import { AreaRegistryEntry } from "./data/area_registry"; import { DeviceRegistryEntry } from "./data/device_registry"; import { EntityRegistryDisplayEntry } from "./data/entity_registry"; +import { LabelRegistryEntry } from "./data/label_registry"; import { CoreFrontendUserData } from "./data/frontend"; import { FrontendLocaleData, getHassTranslations } from "./data/translation"; import { Themes } from "./data/ws-themes"; @@ -208,6 +209,7 @@ export interface HomeAssistant { entities: { [id: string]: EntityRegistryDisplayEntry }; devices: { [id: string]: DeviceRegistryEntry }; areas: { [id: string]: AreaRegistryEntry }; + labels: { [id: string]: LabelRegistryEntry }; services: HassServices; config: HassConfig; themes: Themes; From 8837eede9afd5b01acd0c84520e8aed183f5538f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 25 May 2023 20:29:43 +0200 Subject: [PATCH 3/5] Selectors and stuff --- demo/src/ha-demo.ts | 2 + demo/src/stubs/label_registry.ts | 7 + gallery/src/pages/automation/editor-action.ts | 2 + .../src/pages/automation/editor-condition.ts | 2 + .../src/pages/automation/editor-trigger.ts | 2 + gallery/src/pages/components/ha-form.ts | 31 ++ gallery/src/pages/components/ha-selector.ts | 36 +- gallery/src/pages/misc/integration-card.ts | 2 + src/components/ha-label-picker.ts | 465 ++++++++++++++++++ src/components/ha-labels-picker.ts | 166 +++++++ .../ha-selector/ha-selector-label.ts | 132 +++++ src/components/ha-selector/ha-selector.ts | 1 + src/components/ha-service-control.ts | 24 +- src/components/ha-target-picker.ts | 90 +++- src/data/script.ts | 1 + src/data/script_i18n.ts | 8 + src/data/selector.ts | 48 ++ src/fake_data/provide_hass.ts | 1 + src/translations/en.json | 23 +- 19 files changed, 1033 insertions(+), 10 deletions(-) create mode 100644 demo/src/stubs/label_registry.ts create mode 100644 src/components/ha-label-picker.ts create mode 100644 src/components/ha-labels-picker.ts create mode 100644 src/components/ha-selector/ha-selector-label.ts diff --git a/demo/src/ha-demo.ts b/demo/src/ha-demo.ts index 17550a6af68e..0935416ba3c7 100644 --- a/demo/src/ha-demo.ts +++ b/demo/src/ha-demo.ts @@ -74,6 +74,7 @@ export class HaDemo extends HomeAssistantAppEl { has_entity_name: false, unique_id: "co2_intensity", options: null, + labels: [], }, { config_entry_id: "co2signal", @@ -90,6 +91,7 @@ export class HaDemo extends HomeAssistantAppEl { has_entity_name: false, unique_id: "grid_fossil_fuel_percentage", options: null, + labels: [], }, ]); diff --git a/demo/src/stubs/label_registry.ts b/demo/src/stubs/label_registry.ts new file mode 100644 index 000000000000..27ca8fdc8e9a --- /dev/null +++ b/demo/src/stubs/label_registry.ts @@ -0,0 +1,7 @@ +import { LabelRegistryEntry } from "../../../src/data/label_registry"; +import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; + +export const mockLabelRegistry = ( + hass: MockHomeAssistant, + data: LabelRegistryEntry[] = [] +) => hass.mockWS("config/label_registry/list", () => data); diff --git a/gallery/src/pages/automation/editor-action.ts b/gallery/src/pages/automation/editor-action.ts index 28f9bb16e2f3..d51169b02966 100644 --- a/gallery/src/pages/automation/editor-action.ts +++ b/gallery/src/pages/automation/editor-action.ts @@ -7,6 +7,7 @@ import "../../components/demo-black-white-row"; import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry"; import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry"; import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry"; +import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry"; import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor"; import "../../../../src/panels/config/automation/action/ha-automation-action"; import { HaChooseAction } from "../../../../src/panels/config/automation/action/types/ha-automation-action-choose"; @@ -59,6 +60,7 @@ class DemoHaAutomationEditorAction extends LitElement { mockEntityRegistry(hass); mockDeviceRegistry(hass); mockAreaRegistry(hass); + mockLabelRegistry(hass); mockHassioSupervisor(hass); } diff --git a/gallery/src/pages/automation/editor-condition.ts b/gallery/src/pages/automation/editor-condition.ts index 3a43eda71e0f..74365e07d406 100644 --- a/gallery/src/pages/automation/editor-condition.ts +++ b/gallery/src/pages/automation/editor-condition.ts @@ -7,6 +7,7 @@ import "../../components/demo-black-white-row"; import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry"; import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry"; import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry"; +import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry"; import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor"; import type { ConditionWithShorthand } from "../../../../src/data/automation"; import "../../../../src/panels/config/automation/condition/ha-automation-condition"; @@ -95,6 +96,7 @@ class DemoHaAutomationEditorCondition extends LitElement { mockEntityRegistry(hass); mockDeviceRegistry(hass); mockAreaRegistry(hass); + mockLabelRegistry(hass); mockHassioSupervisor(hass); } diff --git a/gallery/src/pages/automation/editor-trigger.ts b/gallery/src/pages/automation/editor-trigger.ts index 30c9721256e8..b9414d23ae71 100644 --- a/gallery/src/pages/automation/editor-trigger.ts +++ b/gallery/src/pages/automation/editor-trigger.ts @@ -7,6 +7,7 @@ import "../../components/demo-black-white-row"; import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry"; import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry"; import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry"; +import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry"; import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor"; import type { Trigger } from "../../../../src/data/automation"; import { HaGeolocationTrigger } from "../../../../src/panels/config/automation/trigger/types/ha-automation-trigger-geo_location"; @@ -141,6 +142,7 @@ class DemoHaAutomationEditorTrigger extends LitElement { mockEntityRegistry(hass); mockDeviceRegistry(hass); mockAreaRegistry(hass); + mockLabelRegistry(hass); mockHassioSupervisor(hass); } diff --git a/gallery/src/pages/components/ha-form.ts b/gallery/src/pages/components/ha-form.ts index dc8fdb5c5151..62487118b528 100644 --- a/gallery/src/pages/components/ha-form.ts +++ b/gallery/src/pages/components/ha-form.ts @@ -6,6 +6,7 @@ import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry"; import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries"; import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry"; import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry"; +import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry"; import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor"; import { computeInitialHaFormData } from "../../../../src/components/ha-form/compute-initial-ha-form-data"; import "../../../../src/components/ha-form/ha-form"; @@ -58,6 +59,7 @@ const DEVICES = [ hw_version: null, via_device_id: null, serial_number: null, + labels: [], }, { area_id: "backyard", @@ -76,6 +78,7 @@ const DEVICES = [ hw_version: null, via_device_id: null, serial_number: null, + labels: [], }, { area_id: null, @@ -94,6 +97,7 @@ const DEVICES = [ hw_version: null, via_device_id: null, serial_number: null, + labels: [], }, ]; @@ -118,6 +122,30 @@ const AREAS = [ }, ]; +const LABELS = [ + { + label_id: "romantic", + name: "Romantic", + icon: "mdi:heart", + color: "#ff0000", + description: "Lights that can create a romantic atmosphere", + }, + { + label_id: "away", + name: "Away", + icon: "mdi:home-export-outline", + color: "#cccccc", + description: "All that can all be turned off when away from home", + }, + { + label_id: "cleaning", + name: "Cleaning", + icon: "mdi:home-export-outline", + color: "#cccccc", + description: "Everything to turn on while cleaning the house", + }, +]; + const SCHEMAS: { title: string; translations?: Record; @@ -132,6 +160,7 @@ const SCHEMAS: { entity: "Entity", device: "Device", area: "Area", + label: "Label", target: "Target", number: "Number", boolean: "Boolean", @@ -163,6 +192,7 @@ const SCHEMAS: { { name: "Config entry", selector: { config_entry: {} } }, { name: "Duration", selector: { duration: {} } }, { name: "area", selector: { area: {} } }, + { name: "label", selector: { label: {} } }, { name: "target", selector: { target: {} } }, { name: "number", selector: { number: { min: 0, max: 10 } } }, { name: "boolean", selector: { boolean: {} } }, @@ -444,6 +474,7 @@ class DemoHaForm extends LitElement { mockDeviceRegistry(hass, DEVICES); mockConfigEntries(hass); mockAreaRegistry(hass, AREAS); + mockLabelRegistry(hass, LABELS); mockHassioSupervisor(hass); } diff --git a/gallery/src/pages/components/ha-selector.ts b/gallery/src/pages/components/ha-selector.ts index 004bb3f4066f..22005bfcdc52 100644 --- a/gallery/src/pages/components/ha-selector.ts +++ b/gallery/src/pages/components/ha-selector.ts @@ -7,6 +7,7 @@ import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries"; import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry"; import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry"; import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor"; +import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry"; import "../../../../src/components/ha-selector/ha-selector"; import "../../../../src/components/ha-settings-row"; import { BlueprintInput } from "../../../../src/data/blueprint"; @@ -54,6 +55,7 @@ const DEVICES = [ hw_version: null, via_device_id: null, serial_number: null, + labels: [], }, { area_id: "backyard", @@ -72,6 +74,7 @@ const DEVICES = [ hw_version: null, via_device_id: null, serial_number: null, + labels: [], }, { area_id: null, @@ -90,9 +93,7 @@ const DEVICES = [ hw_version: null, via_device_id: null, serial_number: null, - }, ]; - const AREAS = [ { area_id: "backyard", @@ -114,6 +115,30 @@ const AREAS = [ }, ]; +const LABELS = [ + { + label_id: "romantic", + name: "Romantic", + icon: "mdi:heart", + color: "#ff0000", + description: "Lights that can create a romantic atmosphere", + }, + { + label_id: "away", + name: "Away", + icon: "mdi:home-export-outline", + color: "#cccccc", + description: "All that can all be turned off when away from home", + }, + { + label_id: "cleaning", + name: "Cleaning", + icon: "mdi:home-export-outline", + color: "#cccccc", + description: "Everything to turn on while cleaning the house", + }, +]; + const SCHEMAS: { name: string; input: Record; @@ -138,6 +163,7 @@ const SCHEMAS: { duration: { name: "Duration", selector: { duration: {} } }, addon: { name: "Addon", selector: { addon: {} } }, area: { name: "Area", selector: { area: {} } }, + label: { name: "Label", selector: { label: {} } }, target: { name: "Target", selector: { target: {} } }, number_box: { name: "Number Box", @@ -161,8 +187,8 @@ const SCHEMAS: { }, boolean: { name: "Boolean", selector: { boolean: {} } }, time: { name: "Time", selector: { time: {} } }, - date: { name: "Date", selector: { date: {} } }, - datetime: { name: "Date Time", selector: { datetime: {} } }, + // date: { name: "Date", selector: { date: {} } }, + // datetime: { name: "Date Time", selector: { datetime: {} } }, action: { name: "Action", selector: { action: {} } }, text: { name: "Text", @@ -279,6 +305,7 @@ const SCHEMAS: { entity: { name: "Entity", selector: { entity: { multiple: true } } }, device: { name: "Device", selector: { device: { multiple: true } } }, area: { name: "Area", selector: { area: { multiple: true } } }, + label: { name: "Label", selector: { label: { multiple: true } } }, select: { name: "Select Multiple", selector: { @@ -335,6 +362,7 @@ class DemoHaSelector extends LitElement implements ProvideHassElement { mockDeviceRegistry(hass, DEVICES); mockConfigEntries(hass); mockAreaRegistry(hass, AREAS); + mockLabelRegistry(hass, LABELS); mockHassioSupervisor(hass); hass.mockWS("auth/sign_path", (params) => params); hass.mockWS("media_player/browse_media", this._browseMedia); diff --git a/gallery/src/pages/misc/integration-card.ts b/gallery/src/pages/misc/integration-card.ts index ad0e1f2bca38..3fd86fa15fc3 100644 --- a/gallery/src/pages/misc/integration-card.ts +++ b/gallery/src/pages/misc/integration-card.ts @@ -198,6 +198,7 @@ const createEntityRegistryEntries = ( has_entity_name: false, unique_id: "updater", options: null, + labels: [], }, ]; @@ -221,6 +222,7 @@ const createDeviceRegistryEntries = ( name_by_user: null, disabled_by: null, configuration_url: null, + labels: [], }, ]; diff --git a/src/components/ha-label-picker.ts b/src/components/ha-label-picker.ts new file mode 100644 index 000000000000..33fee1e32704 --- /dev/null +++ b/src/components/ha-label-picker.ts @@ -0,0 +1,465 @@ +import "@material/mwc-list/mwc-list-item"; +import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; +import { HassEntity } from "home-assistant-js-websocket"; +import { html, LitElement, PropertyValues, TemplateResult } 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 { + LabelRegistryEntry, + createLabelRegistryEntry, +} from "../data/label_registry"; +import { + DeviceEntityDisplayLookup, + DeviceRegistryEntry, + getDeviceEntityDisplayLookup, +} from "../data/device_registry"; +import { EntityRegistryDisplayEntry } from "../data/entity_registry"; +import { + showAlertDialog, + showPromptDialog, +} from "../dialogs/generic/show-dialog-box"; +import { ValueChangedEvent, HomeAssistant } 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-svg-icon"; + +const rowRenderer: ComboBoxLitRenderer = ( + item +) => html` + ${item.name} +`; + +@customElement("ha-label-picker") +export class HaLabelPicker extends 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?: boolean; + + /** + * Show only labels with entities from specific domains. + * @type {Array} + * @attr include-domains + */ + @property({ type: Array, attribute: "include-domains" }) + public includeDomains?: string[]; + + /** + * Show no label 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-labels" }) + public excludeLabels?: string[]; + + @property() public deviceFilter?: HaDevicePickerDeviceFilterFunc; + + @property() public entityFilter?: (entity: HassEntity) => boolean; + + @property({ type: Boolean }) public disabled?: boolean; + + @property({ type: Boolean }) public required?: boolean; + + @state() private _opened?: boolean; + + @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(); + } + + private _getLabels = memoizeOne( + ( + labels: LabelRegistryEntry[], + 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: "#CCCCCC", + description: 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); + + 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; + + let labelIds: string[] | undefined; + + if (inputDevices) { + labelIds = inputDevices + .filter((device) => device.labels) + .map((device) => device.labels!) + .flat(1); + } + + if (inputEntities) { + labelIds = (labelIds ?? []).concat( + inputEntities + .filter((entity) => entity.labels) + .map((entity) => entity.labels!) + .flat(1) + ); + } + + if (labelIds) { + outputLabels = labels.filter((label) => + labelIds!.includes(label.label_id) + ); + } + + if (excludeLabels) { + outputLabels = outputLabels.filter( + (label) => !excludeLabels!.includes(label.label_id) + ); + } + + if (!outputLabels.length) { + outputLabels = [ + { + label_id: "no_labels", + name: this.hass.localize("ui.components.label-picker.no_match"), + icon: null, + description: null, + color: "#CCCCCC", + }, + ]; + } + + return noAdd + ? outputLabels + : [ + ...outputLabels, + { + label_id: "add_new", + name: this.hass.localize("ui.components.label-picker.add_new"), + icon: null, + description: null, + color: "#CCCCCC", + }, + ]; + } + ); + + protected updated(changedProps: PropertyValues) { + if ( + (!this._init && this.hass) || + (this._init && changedProps.has("_opened") && this._opened) + ) { + this._init = true; + const labels = this._getLabels( + Object.values(this.hass.labels), + Object.values(this.hass.devices), + Object.values(this.hass.entities), + this.includeDomains, + this.excludeDomains, + this.includeDeviceClasses, + this.deviceFilter, + this.entityFilter, + this.noAdd, + this.excludeLabels + ); + (this.comboBox as any).items = labels; + (this.comboBox as any).filteredItems = labels; + } + } + + protected render(): TemplateResult { + return html` + + + `; + } + + private _filterChanged(ev: CustomEvent): void { + const filter = ev.detail.value; + if (!filter) { + this.comboBox.filteredItems = this.comboBox.items; + return; + } + + const filteredItems = this.comboBox.items?.filter((item) => + item.name.toLowerCase().includes(filter!.toLowerCase()) + ); + if (!this.noAdd && filteredItems?.length === 0) { + this._suggestion = filter; + this.comboBox.filteredItems = [ + { + label_id: "add_new_suggestion", + name: this.hass.localize( + "ui.components.label-picker.add_new_sugestion", + { name: this._suggestion } + ), + icon: null, + description: null, + color: null, + }, + ]; + } else { + this.comboBox.filteredItems = filteredItems; + } + } + + private get _value() { + return this.value || ""; + } + + private _openedChanged(ev: ValueChangedEvent) { + this._opened = ev.detail.value; + } + + private _labelChanged(ev: ValueChangedEvent) { + ev.stopPropagation(); + let newValue = ev.detail.value; + + if (newValue === "no_labels") { + newValue = ""; + } + + if (!["add_new_suggestion", "add_new"].includes(newValue)) { + if (newValue !== this._value) { + this._setValue(newValue); + } + return; + } + + (ev.target as any).value = this._value; + showPromptDialog(this, { + title: this.hass.localize("ui.components.label-picker.add_dialog.title"), + text: this.hass.localize("ui.components.label-picker.add_dialog.text"), + confirmText: this.hass.localize( + "ui.components.label-picker.add_dialog.add" + ), + inputLabel: this.hass.localize( + "ui.components.label-picker.add_dialog.name" + ), + defaultValue: + newValue === "add_new_suggestion" ? this._suggestion : undefined, + confirm: async (name) => { + if (!name) { + return; + } + try { + const label = await createLabelRegistryEntry(this.hass, { + name, + }); + const labels = [...Object.values(this.hass.labels), label]; + (this.comboBox as any).filteredItems = this._getLabels( + labels, + 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); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.components.label-picker.add_dialog.failed_create_label" + ), + text: err.message, + }); + } + }, + cancel: () => { + this._setValue(undefined); + this._suggestion = undefined; + }, + }); + } + + 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; + } +} diff --git a/src/components/ha-labels-picker.ts b/src/components/ha-labels-picker.ts new file mode 100644 index 000000000000..d54b28904f11 --- /dev/null +++ b/src/components/ha-labels-picker.ts @@ -0,0 +1,166 @@ +import { HassEntity } from "home-assistant-js-websocket"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../common/dom/fire_event"; +import { SubscribeMixin } from "../mixins/subscribe-mixin"; +import type { HomeAssistant } from "../types"; +import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; +import "./ha-label-picker"; + +@customElement("ha-labels-picker") +export class HaLabelsPicker 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?: boolean; + + /** + * Show only labels with entities from specific domains. + * @type {Array} + * @attr include-domains + */ + @property({ type: Array, attribute: "include-domains" }) + public includeDomains?: string[]; + + /** + * Show no label with entities of these domains. + * @type {Array} + * @attr exclude-domains + */ + @property({ type: Array, attribute: "exclude-domains" }) + public excludeDomains?: string[]; + + /** + * Show only label with entities of these device classes. + * @type {Array} + * @attr include-device-classes + */ + @property({ type: Array, attribute: "include-device-classes" }) + public includeDeviceClasses?: string[]; + + @property() public deviceFilter?: HaDevicePickerDeviceFilterFunc; + + @property() public entityFilter?: (entity: HassEntity) => boolean; + + @property({ attribute: "picked-label-label" }) + public pickedLabelLabel?: string; + + @property({ attribute: "pick-label-label" }) + public pickLabelLabel?: string; + + @property({ type: Boolean }) public disabled?: boolean; + + @property({ type: Boolean }) public required?: boolean; + + protected render() { + if (!this.hass) { + return nothing; + } + + const currentLabels = this._currentLabels; + return html` + ${currentLabels.map( + (label) => html` +
+ +
+ ` + )} +
+ +
+ `; + } + + private get _currentLabels(): string[] { + return this.value || []; + } + + private async _updateLabels(labels) { + this.value = labels; + + fireEvent(this, "value-changed", { + value: labels, + }); + } + + private _labelChanged(ev: CustomEvent) { + ev.stopPropagation(); + const curValue = (ev.currentTarget as any).curValue; + const newValue = ev.detail.value; + if (newValue === curValue) { + return; + } + const currentLabels = this._currentLabels; + if (!newValue || currentLabels.includes(newValue)) { + this._updateLabels(currentLabels.filter((ent) => ent !== curValue)); + return; + } + this._updateLabels( + currentLabels.map((ent) => (ent === curValue ? newValue : ent)) + ); + } + + private _addLabel(ev: CustomEvent) { + ev.stopPropagation(); + + const toAdd = ev.detail.value; + if (!toAdd) { + return; + } + (ev.currentTarget as any).value = ""; + const currentLabels = this._currentLabels; + if (currentLabels.includes(toAdd)) { + return; + } + + this._updateLabels([...currentLabels, toAdd]); + } + + static override styles = css` + div { + margin-top: 8px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-labels-picker": HaLabelsPicker; + } +} diff --git a/src/components/ha-selector/ha-selector-label.ts b/src/components/ha-selector/ha-selector-label.ts new file mode 100644 index 000000000000..8812d64ed133 --- /dev/null +++ b/src/components/ha-selector/ha-selector-label.ts @@ -0,0 +1,132 @@ +import { HassEntity } from "home-assistant-js-websocket"; +import { html, LitElement, PropertyValues, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { ensureArray } from "../../common/array/ensure-array"; +import type { DeviceRegistryEntry } from "../../data/device_registry"; +import { getDeviceIntegrationLookup } from "../../data/device_registry"; +import { + EntitySources, + fetchEntitySourcesWithCache, +} from "../../data/entity_sources"; +import type { LabelSelector } from "../../data/selector"; +import { + filterSelectorDevices, + filterSelectorEntities, +} from "../../data/selector"; +import { HomeAssistant } from "../../types"; +import "../ha-label-picker"; +import "../ha-labels-picker"; + +@customElement("ha-selector-label") +export class HaLabelSelector extends LitElement { + @property() public hass!: HomeAssistant; + + @property() public selector!: LabelSelector; + + @property() public value?: any; + + @property() public label?: string; + + @property() public helper?: string; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = true; + + @state() private _entitySources?: EntitySources; + + private _deviceIntegrationLookup = memoizeOne(getDeviceIntegrationLookup); + + private _hasIntegration(selector: LabelSelector) { + return ( + (selector.label?.entity && + ensureArray(selector.label.entity).some( + (filter) => filter.integration + )) || + (selector.label?.device && + ensureArray(selector.label.device).some((device) => device.integration)) + ); + } + + protected updated(changedProperties: PropertyValues): void { + if ( + changedProperties.has("selector") && + this._hasIntegration(this.selector) && + !this._entitySources + ) { + fetchEntitySourcesWithCache(this.hass).then((sources) => { + this._entitySources = sources; + }); + } + } + + protected render() { + if (this._hasIntegration(this.selector) && !this._entitySources) { + return nothing; + } + + if (!this.selector.label?.multiple) { + return html` + + `; + } + + return html` + + `; + } + + private _filterEntities = (entity: HassEntity): boolean => { + if (!this.selector.label?.entity) { + return true; + } + + return ensureArray(this.selector.label.entity).some((filter) => + filterSelectorEntities(filter, entity, this._entitySources) + ); + }; + + private _filterDevices = (device: DeviceRegistryEntry): boolean => { + if (!this.selector.label?.device) { + return true; + } + + const deviceIntegrations = this._entitySources + ? this._deviceIntegrationLookup( + this._entitySources, + Object.values(this.hass.entities) + ) + : undefined; + + return ensureArray(this.selector.label.device).some((filter) => + filterSelectorDevices(filter, device, deviceIntegrations) + ); + }; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-label": HaLabelSelector; + } +} diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts index 3bfbb7741ed1..86abb6eff5f7 100644 --- a/src/components/ha-selector/ha-selector.ts +++ b/src/components/ha-selector/ha-selector.ts @@ -29,6 +29,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"), diff --git a/src/components/ha-service-control.ts b/src/components/ha-service-control.ts index 5abd36805679..c437853b32b1 100644 --- a/src/components/ha-service-control.ts +++ b/src/components/ha-service-control.ts @@ -19,6 +19,7 @@ import { import { expandAreaTarget, expandDeviceTarget, + expandLabelTarget, Selector, } from "../data/selector"; import { ValueChangedEvent, HomeAssistant } from "../types"; @@ -127,7 +128,8 @@ export class HaServiceControl extends LitElement { "target" in serviceData && (this.value?.data?.entity_id || this.value?.data?.area_id || - this.value?.data?.device_id) + this.value?.data?.device_id || + this.value?.data?.label_id) ) { const target = { ...this.value.target, @@ -142,6 +144,9 @@ export class HaServiceControl extends LitElement { if (this.value.data.device_id && !this.value.target?.device_id) { target.device_id = this.value.data.device_id; } + if (this.value.data.label_id && !this.value.target?.label_id) { + target.label_id = this.value.data.label_id; + } this._value = { ...this.value, @@ -152,6 +157,7 @@ export class HaServiceControl extends LitElement { delete this._value.data!.entity_id; delete this._value.data!.device_id; delete this._value.data!.area_id; + delete this._value.data!.label_id; } else { this._value = this.value; } @@ -263,6 +269,9 @@ export class HaServiceControl extends LitElement { const targetAreas = ensureArray( value?.target?.area_id || value?.data?.area_id )?.slice(); + const targetLabels = ensureArray( + value?.target?.label_id || value?.data?.label_id + )?.slice(); if (targetAreas) { targetAreas.forEach((areaId) => { const expanded = expandAreaTarget( @@ -288,6 +297,19 @@ export class HaServiceControl extends LitElement { ); }); } + if (targetLabels) { + targetLabels.forEach((labelId) => { + const expanded = expandLabelTarget( + this.hass, + labelId, + this.hass.devices, + this.hass.entities, + targetSelector + ); + targetEntities.push(...expanded.entities); + targetDevices.push(...expanded.devices); + }); + } if (!targetEntities.length) { return false; } diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index 08261c5793d6..91eaaebbb855 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -6,6 +6,7 @@ import "@material/mwc-menu/mwc-menu-surface"; import { mdiClose, mdiDevices, + mdiLabelMultiple, mdiPlus, mdiSofa, mdiUnfoldMoreVertical, @@ -34,6 +35,7 @@ import type { HaEntityPickerEntityFilterFunc } from "./entity/ha-entity-picker"; import "./ha-area-picker"; import "./ha-icon-button"; import "./ha-input-helper-text"; +import "./ha-label-picker"; import "./ha-svg-icon"; @customElement("ha-target-picker") @@ -70,7 +72,11 @@ export class HaTargetPicker extends 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?; @@ -123,6 +129,18 @@ export class HaTargetPicker extends LitElement { ); }) : ""} + ${this.value?.label_id + ? ensureArray(this.value.label_id).map((labelId) => { + const label = this.hass.labels![labelId]; + return this._renderChip( + "label_id", + labelId, + label?.name || labelId, + undefined, + mdiLabelMultiple + ); + }) + : ""} `; } @@ -190,6 +208,26 @@ export class HaTargetPicker extends LitElement { +
+
+ + + + ${this.hass.localize( + "ui.components.target-picker.add_label_id" + )} + + +
${this._renderPicker()} ${this.helper @@ -203,7 +241,7 @@ export class HaTargetPicker extends LitElement { } private _renderChip( - type: "area_id" | "device_id" | "entity_id", + type: "area_id" | "device_id" | "entity_id" | "label_id", id: string, name: string, entityState?: HassEntity, @@ -320,7 +358,8 @@ export class HaTargetPicker extends LitElement { @click=${this._preventDefault} > ` - : html` + : this._addMode === "entity_id" + ? html` + ` + : html` + `}`; } @@ -405,6 +462,25 @@ export class HaTargetPicker extends LitElement { newEntities.push(entity.entity_id); } }); + } else if (target.type === "label_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; } @@ -662,6 +738,14 @@ export class HaTargetPicker extends LitElement { .mdc-chip.entity_id.add { background: #d2e7b9; } + .mdc-chip.label_id:not(.add) { + border: 2px solid #eeefff; + background: var(--card-background-color); + } + .mdc-chip.label_id:not(.add) .mdc-chip__icon--leading, + .mdc-chip.label_id.add { + background: #eeefff; + } .mdc-chip:hover { z-index: 5; } diff --git a/src/data/script.ts b/src/data/script.ts index f0b5e7151b51..0967c39c3a18 100644 --- a/src/data/script.ts +++ b/src/data/script.ts @@ -41,6 +41,7 @@ const targetStruct = object({ entity_id: optional(union([string(), array(string())])), device_id: optional(union([string(), array(string())])), area_id: optional(union([string(), array(string())])), + label_id: optional(union([string(), array(string())])), }); export const serviceActionStruct: Describe = assign( diff --git a/src/data/script_i18n.ts b/src/data/script_i18n.ts index 301f585709e4..352cffd40837 100644 --- a/src/data/script_i18n.ts +++ b/src/data/script_i18n.ts @@ -85,6 +85,7 @@ const tryDescribeAction = ( area_id: "areas", device_id: "devices", entity_id: "entities", + label_id: "labels", })) { if (!(key in config.target)) { continue; @@ -146,6 +147,13 @@ const tryDescribeAction = ( ) ); } + } else if (key === "label_id") { + const label_ = hass.labels[targetThing]; + if (label_?.name) { + targets.push(label_.name); + } else { + targets.push("unknown label"); + } } else { targets.push(targetThing); } diff --git a/src/data/selector.ts b/src/data/selector.ts index 1f34ba3e7707..837819672191 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -33,6 +33,7 @@ export type Selector = | LegacyEntitySelector | FileSelector | IconSelector + | LabelSelector | LanguageSelector | LocationSelector | MediaSelector @@ -157,6 +158,14 @@ export interface DeviceSelector { } | null; } +export interface LabelSelector { + label: { + entity?: EntitySelectorFilter | readonly EntitySelectorFilter[]; + device?: DeviceSelectorFilter | readonly DeviceSelectorFilter[]; + multiple?: boolean; + } | null; +} + export interface LegacyDeviceSelector { device: DeviceSelector["device"] & { /** @@ -463,6 +472,45 @@ export const expandDeviceTarget = ( return { entities: newEntities }; }; +export const expandLabelTarget = ( + hass: HomeAssistant, + labelId: string, + devices: HomeAssistant["devices"], + entities: HomeAssistant["entities"], + targetSelector: TargetSelector, + entitySources?: EntitySources +) => { + const newEntities: string[] = []; + const newDevices: string[] = []; + 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 { devices: newDevices, entities: newEntities }; +}; + const deviceMeetsTargetSelector = ( hass: HomeAssistant, entityRegistry: EntityRegistryDisplayEntry[], diff --git a/src/fake_data/provide_hass.ts b/src/fake_data/provide_hass.ts index 111ebf6e3133..73018495f294 100644 --- a/src/fake_data/provide_hass.ts +++ b/src/fake_data/provide_hass.ts @@ -347,6 +347,7 @@ export const provideHass = ( areas: {}, devices: {}, entities: {}, + labels: {}, formatEntityState: (stateObj, state) => (state !== null ? state : stateObj.state) ?? "", formatEntityAttributeName: (_stateObj, attribute) => attribute, diff --git a/src/translations/en.json b/src/translations/en.json index 4120fee79044..353f6d7fa9fd 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -404,13 +404,16 @@ "expand": "Expand", "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 entities.", "remove": "Remove", "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" @@ -476,6 +479,22 @@ "failed_create_area": "Failed to create area." } }, + "label-picker": { + "clear": "Clear", + "show_labels": "Show labels", + "label": "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." + } + }, "statistic-picker": { "statistic": "Statistic", "no_statistics": "You don't have any statistics", @@ -574,7 +593,7 @@ "service-control": { "required": "This field is required", "target": "Targets", - "target_description": "What should this service use as targeted areas, devices or entities.", + "target_description": "What should this service use as targeted areas, devices, entities or labels.", "data": "Service data", "integration_doc": "Integration documentation" }, From 2f9651d65a9eb908069a96cc7bbc055b6e1c5e8e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 25 May 2023 21:52:43 +0200 Subject: [PATCH 4/5] Fix lint warning --- src/panels/config/entities/ha-config-entities.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/panels/config/entities/ha-config-entities.ts b/src/panels/config/entities/ha-config-entities.ts index 8a0dcc6f988a..1bdab5cacd79 100644 --- a/src/panels/config/entities/ha-config-entities.ts +++ b/src/panels/config/entities/ha-config-entities.ts @@ -758,6 +758,7 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) { entity_category: null, has_entity_name: false, options: null, + labels: [], }); } if (changed) { From cde16b251b8be648449efa94ad058175ce5a42ee Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 4 Nov 2023 17:23:29 +0100 Subject: [PATCH 5/5] Tweaks and tunes --- gallery/src/pages/components/ha-selector.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/gallery/src/pages/components/ha-selector.ts b/gallery/src/pages/components/ha-selector.ts index 22005bfcdc52..39bb04ad5bdb 100644 --- a/gallery/src/pages/components/ha-selector.ts +++ b/gallery/src/pages/components/ha-selector.ts @@ -93,6 +93,8 @@ const DEVICES = [ hw_version: null, via_device_id: null, serial_number: null, + labels: [], + }, ]; const AREAS = [ { @@ -187,8 +189,8 @@ const SCHEMAS: { }, boolean: { name: "Boolean", selector: { boolean: {} } }, time: { name: "Time", selector: { time: {} } }, - // date: { name: "Date", selector: { date: {} } }, - // datetime: { name: "Date Time", selector: { datetime: {} } }, + date: { name: "Date", selector: { date: {} } }, + datetime: { name: "Date Time", selector: { datetime: {} } }, action: { name: "Action", selector: { action: {} } }, text: { name: "Text",