From ea5c0145526022fa3c8a7d9e873d5b559c468947 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 17 Jul 2025 16:48:22 +0200 Subject: [PATCH] Add combine mode and fix hidden and entity category for service control --- .../entity/ha-entity-state-picker.ts | 70 ++++++++++++++----- .../ha-selector/ha-selector-state.ts | 1 + src/components/ha-service-control.ts | 50 ++++++++++--- src/components/ha-target-picker.ts | 2 +- src/data/selector.ts | 1 + 5 files changed, 96 insertions(+), 28 deletions(-) diff --git a/src/components/entity/ha-entity-state-picker.ts b/src/components/entity/ha-entity-state-picker.ts index 2d86651b7e..bfaf7f4173 100644 --- a/src/components/entity/ha-entity-state-picker.ts +++ b/src/components/entity/ha-entity-state-picker.ts @@ -11,6 +11,13 @@ import type { HaComboBox } from "../ha-combo-box"; export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean; +interface StateOption { + value: string; + label: string; +} + +const DEFAULT_COMBINE_MODE: "union" | "intersection" = "union"; + @customElement("ha-entity-state-picker") class HaEntityStatePicker extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -34,6 +41,9 @@ class HaEntityStatePicker extends LitElement { @property({ attribute: false }) public hideStates?: string[]; + @property({ attribute: "combine-mode" }) + public combineMode?: "union" | "intersection"; + @property() public label?: string; @property() public value?: string; @@ -56,29 +66,55 @@ class HaEntityStatePicker extends LitElement { changedProps.has("extraOptions") ) { const entityIds = this.entityId ? ensureArray(this.entityId) : []; - const options: { value: string; label: string }[] = []; - const statesSet = new Set(); - for (const entityId of entityIds) { + 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 || !this.hideStates.includes(s) ); - for (const s of states) { - if (!statesSet.has(s)) { - statesSet.add(s); - options.push({ - value: s, - label: !this.attribute - ? this.hass.formatEntityState(stateObj, s) - : this.hass.formatEntityAttributeValue( - stateObj, - this.attribute, - s - ), - }); - } + return states.map((s) => ({ + value: s, + label: !this.attribute + ? this.hass.formatEntityState(stateObj, s) + : this.hass.formatEntityAttributeValue(stateObj, this.attribute, s), + })); + }); + + const mode: "union" | "intersection" = + this.combineMode || DEFAULT_COMBINE_MODE; + + let options: StateOption[] = []; + + if (mode === "union") { + // Union: combine all unique states from all entities + options = entitiesOptions.reduce( + (acc, curr) => [ + ...acc, + ...curr.filter( + (item) => !acc.some((existing) => existing.value === item.value) + ), + ], + [] as StateOption[] + ); + } else if (mode === "intersection") { + // Intersection: only states that exist in ALL entities + if (entitiesOptions.length === 0) { + options = []; + } else if (entitiesOptions.length === 1) { + options = entitiesOptions[0]; + } else { + options = entitiesOptions[0].filter((item) => + entitiesOptions + .slice(1) + .every((entityOptions) => + entityOptions.some((option) => option.value === item.value) + ) + ); } } diff --git a/src/components/ha-selector/ha-selector-state.ts b/src/components/ha-selector/ha-selector-state.ts index fac976e879..8f03a92658 100644 --- a/src/components/ha-selector/ha-selector-state.ts +++ b/src/components/ha-selector/ha-selector-state.ts @@ -42,6 +42,7 @@ export class HaSelectorState extends SubscribeMixin(LitElement) { .required=${this.required} allow-custom-value .hideStates=${this.selector.state?.hide_states} + .combineMode=${this.selector.state?.combine_mode} > `; } diff --git a/src/components/ha-service-control.ts b/src/components/ha-service-control.ts index 3634db2e2c..ba6c0f4615 100644 --- a/src/components/ha-service-control.ts +++ b/src/components/ha-service-control.ts @@ -55,6 +55,20 @@ const showOptionalToggle = (field) => !field.required && !("boolean" in field.selector && field.default); +const enrichSelector = (selector: Selector): Selector => { + // Default combine_mode to intersection for state selectors + if ("state" in selector) { + return { + ...selector, + state: { + ...selector.state, + combine_mode: selector.state?.combine_mode || "intersection", + }, + }; + } + return selector; +}; + interface Field extends Omit { key: string; selector?: Selector; @@ -244,7 +258,9 @@ export class HaServiceControl extends LitElement { ).map(([key, value]) => ({ key, ...value, - selector: value.selector as Selector | undefined, + selector: (value.selector + ? enrichSelector(value.selector) + : undefined) as Selector | undefined, })); const flatFields: Field[] = []; @@ -314,7 +330,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 +359,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; 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 3dfba90532..8fdb04589d 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -396,6 +396,7 @@ export interface StateSelector { entity_id?: string | string[]; attribute?: string; hide_states?: string[]; + combine_mode?: "union" | "intersection"; } | null; }