From 3d1c908a01c758897c16cdd65bceafb5a8a76e14 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 21 Jul 2025 07:48:25 +0200 Subject: [PATCH] Add support for multiple entities and hide_states option in state and attribute selectors (#26207) * Add support for multiple entities and target for state selector * Add support for multiple entities for attribute selector * Improve context support * Add combine mode and fix hidden and entity category for service control * Don't use combine mode * Refactor options --- .../entity/ha-entity-attribute-picker.ts | 93 ++++++++++++------- .../entity/ha-entity-state-picker.ts | 66 +++++++++---- .../ha-selector/ha-selector-attribute.ts | 16 +++- .../ha-selector/ha-selector-state.ts | 3 +- src/components/ha-service-control.ts | 37 ++++++-- src/components/ha-target-picker.ts | 2 +- src/data/selector.ts | 5 +- .../types/ha-automation-trigger-state.ts | 27 +++--- 8 files changed, 169 insertions(+), 80 deletions(-) diff --git a/src/components/entity/ha-entity-attribute-picker.ts b/src/components/entity/ha-entity-attribute-picker.ts index 7e08060e33..eaa62ff761 100644 --- a/src/components/entity/ha-entity-attribute-picker.ts +++ b/src/components/entity/ha-entity-attribute-picker.ts @@ -2,18 +2,24 @@ import type { HassEntity } from "home-assistant-js-websocket"; import type { PropertyValues } from "lit"; import { LitElement, html, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; -import { computeAttributeNameDisplay } from "../../common/entity/compute_attribute_display"; +import { ensureArray } from "../../common/array/ensure-array"; +import { fireEvent } from "../../common/dom/fire_event"; import type { HomeAssistant, ValueChangedEvent } from "../../types"; import "../ha-combo-box"; import type { HaComboBox } from "../ha-combo-box"; export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean; +interface AttributeOption { + value: string; + label: string; +} + @customElement("ha-entity-attribute-picker") class HaEntityAttributePicker extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @property({ attribute: false }) public entityId?: string; + @property({ attribute: false }) public entityId?: string | string[]; /** * List of attributes to be hidden. @@ -48,23 +54,40 @@ class HaEntityAttributePicker extends LitElement { } protected updated(changedProps: PropertyValues) { - if (changedProps.has("_opened") && this._opened) { - const entityState = this.entityId - ? this.hass.states[this.entityId] - : undefined; - (this._comboBox as any).items = entityState - ? Object.keys(entityState.attributes) - .filter((key) => !this.hideAttributes?.includes(key)) - .map((key) => ({ - value: key, - label: computeAttributeNameDisplay( - this.hass.localize, - entityState, - this.hass.entities, - key - ), - })) - : []; + if ( + (changedProps.has("_opened") && this._opened) || + changedProps.has("entityId") || + changedProps.has("attribute") + ) { + const entityIds = this.entityId ? ensureArray(this.entityId) : []; + const entitiesOptions = entityIds.map((entityId) => { + const stateObj = this.hass.states[entityId]; + if (!stateObj) { + return []; + } + + const attributes = Object.keys(stateObj.attributes).filter( + (a) => !this.hideAttributes?.includes(a) + ); + + return attributes.map((a) => ({ + value: a, + label: this.hass.formatEntityAttributeName(stateObj, a), + })); + }); + + const options: AttributeOption[] = []; + const optionsSet = new Set(); + for (const entityOptions of entitiesOptions) { + for (const option of entityOptions) { + if (!optionsSet.has(option.value)) { + optionsSet.add(option.value); + options.push(option); + } + } + } + + (this._comboBox as any).filteredItems = options; } } @@ -73,21 +96,10 @@ class HaEntityAttributePicker extends LitElement { return nothing; } - const stateObj = this.hass.states[this.entityId!] as HassEntity | undefined; - return html` ) { this._opened = ev.detail.value; } private _valueChanged(ev: ValueChangedEvent) { - this.value = ev.detail.value; + ev.stopPropagation(); + const newValue = ev.detail.value; + if (newValue !== this._value) { + this._setValue(newValue); + } + } + + private _setValue(value: string) { + this.value = value; + setTimeout(() => { + fireEvent(this, "value-changed", { value }); + fireEvent(this, "change"); + }, 0); } } diff --git a/src/components/entity/ha-entity-state-picker.ts b/src/components/entity/ha-entity-state-picker.ts index f54a8e5cd8..9cd3b05f7a 100644 --- a/src/components/entity/ha-entity-state-picker.ts +++ b/src/components/entity/ha-entity-state-picker.ts @@ -2,6 +2,7 @@ import type { HassEntity } from "home-assistant-js-websocket"; import type { PropertyValues } from "lit"; import { LitElement, html, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; +import { ensureArray } from "../../common/array/ensure-array"; import { fireEvent } from "../../common/dom/fire_event"; import { getStates } from "../../common/entity/get_states"; import type { HomeAssistant, ValueChangedEvent } from "../../types"; @@ -10,11 +11,16 @@ import type { HaComboBox } from "../ha-combo-box"; export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean; +interface StateOption { + value: string; + label: string; +} + @customElement("ha-entity-state-picker") class HaEntityStatePicker extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @property({ attribute: false }) public entityId?: string; + @property({ attribute: false }) public entityId?: string | string[]; @property() public attribute?: string; @@ -30,6 +36,9 @@ class HaEntityStatePicker extends LitElement { @property({ type: Boolean, attribute: "allow-custom-value" }) public allowCustomValue; + @property({ attribute: false }) + public hideStates?: string[]; + @property() public label?: string; @property() public value?: string; @@ -51,24 +60,42 @@ class HaEntityStatePicker extends LitElement { changedProps.has("attribute") || changedProps.has("extraOptions") ) { - const stateObj = this.entityId - ? this.hass.states[this.entityId] - : undefined; - (this._comboBox as any).items = [ - ...(this.extraOptions ?? []), - ...(this.entityId && stateObj - ? getStates(this.hass, stateObj, this.attribute).map((key) => ({ - value: key, - label: !this.attribute - ? this.hass.formatEntityState(stateObj, key) - : this.hass.formatEntityAttributeValue( - stateObj, - this.attribute, - key - ), - })) - : []), - ]; + const entityIds = this.entityId ? ensureArray(this.entityId) : []; + + const entitiesOptions = entityIds.map((entityId) => { + const stateObj = this.hass.states[entityId]; + if (!stateObj) { + return []; + } + + const states = getStates(this.hass, stateObj, this.attribute).filter( + (s) => !this.hideStates?.includes(s) + ); + + return states.map((s) => ({ + value: s, + label: this.attribute + ? this.hass.formatEntityAttributeValue(stateObj, this.attribute, s) + : this.hass.formatEntityState(stateObj, s), + })); + }); + + const options: StateOption[] = []; + const optionsSet = new Set(); + for (const entityOptions of entitiesOptions) { + for (const option of entityOptions) { + if (!optionsSet.has(option.value)) { + optionsSet.add(option.value); + options.push(option); + } + } + } + + if (this.extraOptions) { + options.unshift(...this.extraOptions); + } + + (this._comboBox as any).filteredItems = options; } } @@ -88,6 +115,7 @@ class HaEntityStatePicker extends LitElement { .required=${this.required} .helper=${this.helper} .allowCustomValue=${this.allowCustomValue} + item-id-path="value" item-value-path="value" item-label-path="label" @opened-changed=${this._openedChanged} diff --git a/src/components/ha-selector/ha-selector-attribute.ts b/src/components/ha-selector/ha-selector-attribute.ts index 5296bc4be4..ca607ccf14 100644 --- a/src/components/ha-selector/ha-selector-attribute.ts +++ b/src/components/ha-selector/ha-selector-attribute.ts @@ -5,6 +5,7 @@ import { fireEvent } from "../../common/dom/fire_event"; import type { AttributeSelector } from "../../data/selector"; import type { HomeAssistant } from "../../types"; import "../entity/ha-entity-attribute-picker"; +import { ensureArray } from "../../common/array/ensure-array"; @customElement("ha-selector-attribute") export class HaSelectorAttribute extends LitElement { @@ -23,7 +24,7 @@ export class HaSelectorAttribute extends LitElement { @property({ type: Boolean }) public required = true; @property({ attribute: false }) public context?: { - filter_entity?: string; + filter_entity?: string | string[]; }; protected render() { @@ -69,11 +70,16 @@ export class HaSelectorAttribute extends LitElement { // Validate that that the attribute is still valid for this entity, else unselect. let invalid = false; if (this.context.filter_entity) { - const stateObj = this.hass.states[this.context.filter_entity]; + const entityIds = ensureArray(this.context.filter_entity); - if (!(stateObj && this.value in stateObj.attributes)) { - invalid = true; - } + invalid = !entityIds.some((entityId) => { + const stateObj = this.hass.states[entityId]; + return ( + stateObj && + this.value in stateObj.attributes && + stateObj.attributes[this.value] !== undefined + ); + }); } else { invalid = this.value !== undefined; } diff --git a/src/components/ha-selector/ha-selector-state.ts b/src/components/ha-selector/ha-selector-state.ts index 8350694a33..fac976e879 100644 --- a/src/components/ha-selector/ha-selector-state.ts +++ b/src/components/ha-selector/ha-selector-state.ts @@ -23,7 +23,7 @@ export class HaSelectorState extends SubscribeMixin(LitElement) { @property({ attribute: false }) public context?: { filter_attribute?: string; - filter_entity?: string; + filter_entity?: string | string[]; }; protected render() { @@ -41,6 +41,7 @@ export class HaSelectorState extends SubscribeMixin(LitElement) { .disabled=${this.disabled} .required=${this.required} allow-custom-value + .hideStates=${this.selector.state?.hide_states} > `; } diff --git a/src/components/ha-service-control.ts b/src/components/ha-service-control.ts index 898959277d..bc7f986070 100644 --- a/src/components/ha-service-control.ts +++ b/src/components/ha-service-control.ts @@ -314,7 +314,12 @@ export class HaServiceControl extends LitElement { targetSelector ); targetDevices.push(...expanded.devices); - targetEntities.push(...expanded.entities); + const primaryEntities = expanded.entities.filter( + (entityId) => + !this.hass.entities[entityId]?.entity_category && + !this.hass.entities[entityId]?.hidden + ); + targetEntities.push(primaryEntities); targetAreas.push(...expanded.areas); }); } @@ -338,20 +343,29 @@ export class HaServiceControl extends LitElement { this.hass.entities, targetSelector ); - targetEntities.push(...expanded.entities); + const primaryEntities = expanded.entities.filter( + (entityId) => + !this.hass.entities[entityId]?.entity_category && + !this.hass.entities[entityId]?.hidden + ); + targetEntities.push(...primaryEntities); targetDevices.push(...expanded.devices); }); } if (targetDevices.length) { targetDevices.forEach((deviceId) => { - targetEntities.push( - ...expandDeviceTarget( - this.hass, - deviceId, - this.hass.entities, - targetSelector - ).entities + const expanded = expandDeviceTarget( + this.hass, + deviceId, + this.hass.entities, + targetSelector ); + const primaryEntities = expanded.entities.filter( + (entityId) => + !this.hass.entities[entityId]?.entity_category && + !this.hass.entities[entityId]?.hidden + ); + targetEntities.push(...primaryEntities); }); } return targetEntities; @@ -675,6 +689,7 @@ export class HaServiceControl extends LitElement { ) || dataField?.description} ({ + filter_entity: targetEntities || undefined, + })); + private _localizeValueCallback = (key: string) => { if (!this._value?.action) { return ""; diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index 0f5a4ae05c..6088764c3c 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -719,7 +719,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { } private _entityRegMeetsFilter(entity: EntityRegistryDisplayEntry): boolean { - if (entity.entity_category) { + if (entity.hidden || entity.entity_category) { return false; } diff --git a/src/data/selector.ts b/src/data/selector.ts index 5086d435a9..834746ae17 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -98,7 +98,7 @@ export interface AreasDisplaySelector { export interface AttributeSelector { attribute: { - entity_id?: string; + entity_id?: string | string[]; hide_attributes?: readonly string[]; } | null; } @@ -394,8 +394,9 @@ export interface SelectorSelector { export interface StateSelector { state: { extra_options?: { label: string; value: any }[]; - entity_id?: string; + entity_id?: string | string[]; attribute?: string; + hide_states?: string[]; } | null; } diff --git a/src/panels/config/automation/trigger/types/ha-automation-trigger-state.ts b/src/panels/config/automation/trigger/types/ha-automation-trigger-state.ts index 9f1f94cbdc..be0f286ba6 100644 --- a/src/panels/config/automation/trigger/types/ha-automation-trigger-state.ts +++ b/src/panels/config/automation/trigger/types/ha-automation-trigger-state.ts @@ -24,7 +24,10 @@ import { baseTriggerStruct, forDictStruct } from "../../structs"; import type { TriggerElement } from "../ha-automation-trigger-row"; import "../../../../../components/ha-form/ha-form"; import { createDurationData } from "../../../../../common/datetime/create_duration_data"; -import type { SchemaUnion } from "../../../../../components/ha-form/types"; +import type { + HaFormSchema, + SchemaUnion, +} from "../../../../../components/ha-form/types"; const stateTriggerStruct = assign( baseTriggerStruct, @@ -54,7 +57,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement { } private _schema = memoizeOne( - (localize: LocalizeFunc, entityId, attribute) => + (localize: LocalizeFunc, attribute) => [ { name: "entity_id", @@ -63,9 +66,11 @@ export class HaStateTrigger extends LitElement implements TriggerElement { }, { name: "attribute", + context: { + filter_entity: "entity_id", + }, selector: { attribute: { - entity_id: entityId ? entityId[0] : undefined, hide_attributes: [ "access_token", "available_modes", @@ -121,6 +126,9 @@ export class HaStateTrigger extends LitElement implements TriggerElement { }, { name: "from", + context: { + filter_entity: "entity_id", + }, selector: { state: { extra_options: (attribute @@ -133,13 +141,15 @@ export class HaStateTrigger extends LitElement implements TriggerElement { value: ANY_STATE_VALUE, }, ]) as any, - entity_id: entityId ? entityId[0] : undefined, attribute: attribute, }, }, }, { name: "to", + context: { + filter_entity: "entity_id", + }, selector: { state: { extra_options: (attribute @@ -152,13 +162,12 @@ export class HaStateTrigger extends LitElement implements TriggerElement { value: ANY_STATE_VALUE, }, ]) as any, - entity_id: entityId ? entityId[0] : undefined, attribute: attribute, }, }, }, { name: "for", selector: { duration: {} } }, - ] as const + ] as const satisfies HaFormSchema[] ); public shouldUpdate(changedProperties: PropertyValues) { @@ -204,11 +213,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement { if (!data.attribute && data.from === null) { data.from = ANY_STATE_VALUE; } - const schema = this._schema( - this.hass.localize, - this.trigger.entity_id, - this.trigger.attribute - ); + const schema = this._schema(this.hass.localize, this.trigger.attribute); return html`