From 8e3b1dc6ac546589c670d0adcc990fcc363c4804 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 31 Mar 2026 14:42:15 +0200 Subject: [PATCH] Triggers/conditions Add usage and grouping to new multi domains (#51287) --- src/data/icons.ts | 24 ++ .../add-automation-element-dialog.ts | 316 +++++++++++++----- 2 files changed, 253 insertions(+), 87 deletions(-) diff --git a/src/data/icons.ts b/src/data/icons.ts index c5140f59d4..cd37e84d71 100644 --- a/src/data/icons.ts +++ b/src/data/icons.ts @@ -3,8 +3,10 @@ import { mdiAirFilter, mdiAlert, mdiAppleSafari, + mdiBattery, mdiBell, mdiBookmark, + mdiBrightness6, mdiBullhorn, mdiButtonPointer, mdiCalendar, @@ -16,13 +18,18 @@ import { mdiCog, mdiCommentAlert, mdiCounter, + mdiDoorOpen, mdiEye, + mdiFlash, mdiFlower, mdiFormatListBulleted, mdiFormTextbox, mdiForumOutline, + mdiGarageOpen, + mdiGate, mdiGoogleAssistant, mdiGoogleCirclesCommunities, + mdiHomeAccount, mdiHomeAutomation, mdiImage, mdiImageFilterFrames, @@ -30,6 +37,7 @@ import { mdiLightbulb, mdiMapMarkerRadius, mdiMicrophoneMessage, + mdiMotionSensor, mdiPalette, mdiRayVertex, mdiRemote, @@ -41,10 +49,14 @@ import { mdiSpeakerMessage, mdiStarFourPoints, mdiThermostat, + mdiThermometer, mdiTimerOutline, mdiToggleSwitch, + mdiWater, + mdiWaterPercent, mdiWeatherPartlyCloudy, mdiWhiteBalanceSunny, + mdiWindowClosed, } from "@mdi/js"; import type { HassEntity } from "home-assistant-js-websocket"; import { isComponentLoaded } from "../common/config/is_component_loaded"; @@ -75,6 +87,7 @@ export const FALLBACK_DOMAIN_ICONS = { air_quality: mdiAirFilter, alert: mdiAlert, automation: mdiRobot, + battery: mdiBattery, calendar: mdiCalendar, climate: mdiThermostat, configurator: mdiCog, @@ -84,10 +97,15 @@ export const FALLBACK_DOMAIN_ICONS = { datetime: mdiCalendarClock, demo: mdiHomeAssistant, device_tracker: mdiAccount, + door: mdiDoorOpen, + garage_door: mdiGarageOpen, + gate: mdiGate, google_assistant: mdiGoogleAssistant, group: mdiGoogleCirclesCommunities, homeassistant: mdiHomeAssistant, homekit: mdiHomeAutomation, + humidity: mdiWaterPercent, + illuminance: mdiBrightness6, image_processing: mdiImageFilterFrames, image: mdiImage, infrared: mdiLedOn, @@ -99,11 +117,15 @@ export const FALLBACK_DOMAIN_ICONS = { input_text: mdiFormTextbox, lawn_mower: mdiRobotMower, light: mdiLightbulb, + moisture: mdiWater, + motion: mdiMotionSensor, notify: mdiCommentAlert, number: mdiRayVertex, + occupancy: mdiHomeAccount, persistent_notification: mdiBell, person: mdiAccount, plant: mdiFlower, + power: mdiFlash, proximity: mdiAppleSafari, remote: mdiRemote, scene: mdiPalette, @@ -115,6 +137,7 @@ export const FALLBACK_DOMAIN_ICONS = { siren: mdiBullhorn, stt: mdiMicrophoneMessage, sun: mdiWhiteBalanceSunny, + temperature: mdiThermometer, text: mdiFormTextbox, time: mdiClock, timer: mdiTimerOutline, @@ -124,6 +147,7 @@ export const FALLBACK_DOMAIN_ICONS = { vacuum: mdiRobotVacuum, wake_word: mdiChatSleep, weather: mdiWeatherPartlyCloudy, + window: mdiWindowClosed, zone: mdiMapMarkerRadius, }; diff --git a/src/panels/config/automation/add-automation-element-dialog.ts b/src/panels/config/automation/add-automation-element-dialog.ts index e2204b2869..1b9a6a4180 100644 --- a/src/panels/config/automation/add-automation-element-dialog.ts +++ b/src/panels/config/automation/add-automation-element-dialog.ts @@ -17,6 +17,7 @@ import { customElement, property, query, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { repeat } from "lit/directives/repeat"; import memoizeOne from "memoize-one"; +import { ensureArray } from "../../../common/array/ensure-array"; import { fireEvent } from "../../../common/dom/fire_event"; import { mainWindow } from "../../../common/dom/get_main_window"; import { computeAreaName } from "../../../common/entity/compute_area_name"; @@ -98,6 +99,7 @@ import { domainToName, fetchIntegrationManifests, } from "../../../data/integration"; +import { filterSelectorEntities } from "../../../data/selector"; import type { LabelRegistryEntry } from "../../../data/label/label_registry"; import { subscribeLabFeature } from "../../../data/labs"; import { @@ -412,6 +414,86 @@ class DialogAddAutomationElement } } + private _calculateActiveSystemDomains = memoizeOne( + ( + descriptions: TriggerDescriptions | ConditionDescriptions, + manifests: DomainManifestLookup, + getDomain: (key: string) => string + ): { active: Set; byEntityDomain: Map> } => { + const active = new Set(); + // Group all entity filters by system domain + const domainFilters: Record< + string, + Parameters[0][] + > = {}; + // Also collect which entity domains each system domain targets + const entityDomainsPerSystemDomain: Record> = {}; + for (const [key, desc] of Object.entries(descriptions)) { + const domain = getDomain(key); + if (manifests[domain]?.integration_type !== "system") { + continue; + } + if (!domainFilters[domain]) { + domainFilters[domain] = []; + entityDomainsPerSystemDomain[domain] = new Set(); + } + const entityFilters = ensureArray(desc.target?.entity); + if (entityFilters) { + // target.entity can be EntitySelectorFilter | readonly EntitySelectorFilter[] + // ensureArray wraps it but each element may still be an array, so flatten + for (const filterOrArray of entityFilters) { + const filters = ensureArray(filterOrArray); + domainFilters[domain].push(...filters); + for (const filter of filters) { + for (const entityDomain of ensureArray(filter.domain) ?? []) { + entityDomainsPerSystemDomain[domain].add(entityDomain); + } + } + } + } + } + // Check each entity in hass.states against the filters + for (const entity of Object.values(this.hass.states)) { + for (const [domain, filters] of Object.entries(domainFilters)) { + if (active.has(domain)) { + continue; + } + if (filters.some((f) => filterSelectorEntities(f, entity))) { + active.add(domain); + } + } + } + // Build reverse map: entity domain → set of system domains that cover it + const byEntityDomain = new Map>(); + for (const [systemDomain, entityDomains] of Object.entries( + entityDomainsPerSystemDomain + )) { + for (const entityDomain of entityDomains) { + if (!byEntityDomain.has(entityDomain)) { + byEntityDomain.set(entityDomain, new Set()); + } + byEntityDomain.get(entityDomain)!.add(systemDomain); + } + } + return { active, byEntityDomain }; + } + ); + + private get _systemDomains() { + if (!this._manifests) { + return undefined; + } + const descriptions = + this._params?.type === "trigger" + ? this._triggerDescriptions + : this._conditionDescriptions; + return this._calculateActiveSystemDomains( + descriptions, + this._manifests, + this._params?.type === "trigger" ? getTriggerDomain : getConditionDomain + ); + } + private async _loadConfigEntries() { const configEntries = await getConfigEntries(this.hass); this._configEntryLookup = Object.fromEntries( @@ -426,6 +508,11 @@ class DialogAddAutomationElement manifests[manifest.domain] = manifest; } this._manifests = manifests; + // If a target was already selected and items computed before manifests + // loaded, recompute so system domain grouping applies correctly. + if (this._selectedTarget && this._targetItems) { + this._getItemsByTarget(); + } } public disconnectedCallback(): void { @@ -905,7 +992,8 @@ class DialogAddAutomationElement this._domains, this.hass.localize, this.hass.services, - this._manifests + this._manifests, + this._systemDomains?.byEntityDomain ), }, ] @@ -954,10 +1042,17 @@ class DialogAddAutomationElement const items = flattenGroups(groups).flat(); if (type === "trigger") { - items.push(...this._triggers(localize, this._triggerDescriptions)); + items.push( + ...this._triggers(localize, this._triggerDescriptions, undefined) + ); } else if (type === "condition") { items.push( - ...this._conditions(localize, this._conditionDescriptions, manifests) + ...this._conditions( + localize, + this._conditionDescriptions, + manifests, + undefined + ) ); } else if (type === "action") { items.push(...this._services(localize, services, manifests)); @@ -1141,16 +1236,23 @@ class DialogAddAutomationElement domains: Set | undefined, localize: LocalizeFunc, services: HomeAssistant["services"], - manifests?: DomainManifestLookup + manifests?: DomainManifestLookup, + systemDomainsByEntityDomain?: Map> ): AddAutomationElementListItem[] => { if (type === "trigger" && isDynamic(group)) { - return this._triggers(localize, this._triggerDescriptions, group); + return this._triggers( + localize, + this._triggerDescriptions, + systemDomainsByEntityDomain, + group + ); } if (type === "condition" && isDynamic(group)) { return this._conditions( localize, this._conditionDescriptions, manifests, + systemDomainsByEntityDomain, group ); } @@ -1250,6 +1352,38 @@ class DialogAddAutomationElement ); }; + private _domainMatchesGroupType( + domain: string, + manifest: DomainManifestLookup[string] | undefined, + domainUsed: boolean, + type: "helper" | "other" | undefined + ): boolean { + if (type === undefined) { + return ( + ENTITY_DOMAINS_MAIN.has(domain) || + (manifest?.integration_type === "entity" && + domainUsed && + !ENTITY_DOMAINS_OTHER.has(domain)) || + (manifest?.integration_type === "system" && + (this._systemDomains?.active.has(domain) ?? false)) + ); + } + if (type === "helper") { + return manifest?.integration_type === "helper"; + } + // type === "other" + return ( + !ENTITY_DOMAINS_MAIN.has(domain) && + (ENTITY_DOMAINS_OTHER.has(domain) || + (!domainUsed && manifest?.integration_type === "entity") || + (manifest?.integration_type === "system" && + !(this._systemDomains?.active.has(domain) ?? false)) || + !["helper", "entity", "system"].includes( + manifest?.integration_type || "" + )) + ); + } + private _triggerGroups = ( localize: LocalizeFunc, triggers: TriggerDescriptions, @@ -1273,19 +1407,7 @@ class DialogAddAutomationElement const manifest = manifests[domain]; const domainUsed = !domains ? true : domains.has(domain); - if ( - (type === undefined && - (ENTITY_DOMAINS_MAIN.has(domain) || - (manifest?.integration_type === "entity" && - domainUsed && - !ENTITY_DOMAINS_OTHER.has(domain)))) || - (type === "helper" && manifest?.integration_type === "helper") || - (type === "other" && - !ENTITY_DOMAINS_MAIN.has(domain) && - (ENTITY_DOMAINS_OTHER.has(domain) || - (!domainUsed && manifest?.integration_type === "entity") || - !["helper", "entity"].includes(manifest?.integration_type || ""))) - ) { + if (this._domainMatchesGroupType(domain, manifest, domainUsed, type)) { result.push({ icon: html` > | undefined, group?: string ): AddAutomationElementListItem[] => { if (!triggers) { return []; } + const browsedEntityDomain = + group && isDynamic(group) ? getValueFromDynamic(group) : undefined; + + // System domains that should be merged into this entity domain group + const systemDomainsForGroup = browsedEntityDomain + ? systemDomainsByEntityDomain?.get(browsedEntityDomain) + : undefined; + return this._getTriggerListItems( localize, Object.keys(triggers).filter((trigger) => { const domain = getTriggerDomain(trigger); - return !group || group === `${DYNAMIC_PREFIX}${domain}`; + if (!group || group === `${DYNAMIC_PREFIX}${domain}`) { + return true; + } + // Also include system domain triggers that cover the browsed entity domain + return systemDomainsForGroup?.has(domain) ?? false; }) ); } @@ -1348,19 +1483,7 @@ class DialogAddAutomationElement const manifest = manifests[domain]; const domainUsed = !domains ? true : domains.has(domain); - if ( - (type === undefined && - (ENTITY_DOMAINS_MAIN.has(domain) || - (manifest?.integration_type === "entity" && - domainUsed && - !ENTITY_DOMAINS_OTHER.has(domain)))) || - (type === "helper" && manifest?.integration_type === "helper") || - (type === "other" && - !ENTITY_DOMAINS_MAIN.has(domain) && - (ENTITY_DOMAINS_OTHER.has(domain) || - (!domainUsed && manifest?.integration_type === "entity") || - !["helper", "entity"].includes(manifest?.integration_type || ""))) - ) { + if (this._domainMatchesGroupType(domain, manifest, domainUsed, type)) { result.push({ icon: html` > | undefined, group?: string ): AddAutomationElementListItem[] => { if (!conditions) { @@ -1392,10 +1516,21 @@ class DialogAddAutomationElement } const result: AddAutomationElementListItem[] = []; + const browsedEntityDomain = + group && isDynamic(group) ? getValueFromDynamic(group) : undefined; + + const systemDomainsForGroup = browsedEntityDomain + ? systemDomainsByEntityDomain?.get(browsedEntityDomain) + : undefined; + for (const condition of Object.keys(conditions)) { const domain = getConditionDomain(condition); - if (group && group !== `${DYNAMIC_PREFIX}${domain}`) { + if ( + group && + group !== `${DYNAMIC_PREFIX}${domain}` && + !(systemDomainsForGroup?.has(domain) ?? false) + ) { continue; } @@ -1491,13 +1626,23 @@ class DialogAddAutomationElement ); private _getDomainType(domain: string) { - return ENTITY_DOMAINS_MAIN.has(domain) || - (this._manifests?.[domain].integration_type === "entity" && + if ( + ENTITY_DOMAINS_MAIN.has(domain) || + (this._manifests?.[domain]?.integration_type === "entity" && !ENTITY_DOMAINS_OTHER.has(domain)) - ? "dynamicGroups" - : this._manifests?.[domain].integration_type === "helper" - ? "helpers" - : "other"; + ) { + return "dynamicGroups"; + } + if (this._manifests?.[domain]?.integration_type === "helper") { + return "helpers"; + } + if ( + this._manifests?.[domain]?.integration_type === "system" && + this._systemDomains?.active.has(domain) + ) { + return "dynamicGroups"; + } + return "other"; } private _sortDomainsByCollection( @@ -1602,30 +1747,56 @@ class DialogAddAutomationElement iconPath: options.icon || TYPES[type].icons[key], }); - private _getDomainGroupedTriggerListItems( + private _getDomainGroupedListItems( localize: LocalizeFunc, - triggerIds: string[] + ids: string[], + getDomain: (id: string) => string, + getListItem: ( + localize: LocalizeFunc, + domain: string, + id: string + ) => AddAutomationElementListItem ): { title: string; items: AddAutomationElementListItem[] }[] { const items: Record< string, { title: string; items: AddAutomationElementListItem[] } > = {}; - triggerIds.forEach((trigger) => { - const domain = getTriggerDomain(trigger); + // When a specific entity is selected, system domain items are merged + // under the entity's real domain rather than under their system domain name. + const targetEntityId = this._selectedTarget?.entity_id; + const targetEntityDomain = + targetEntityId && + this._manifests?.[computeDomain(targetEntityId)]?.integration_type !== + "system" + ? computeDomain(targetEntityId) + : undefined; - if (!items[domain]) { - items[domain] = { - title: domainToName(localize, domain, this._manifests?.[domain]), + ids.forEach((id) => { + const itemDomain = getDomain(id); + const isSystemDomain = + this._manifests?.[itemDomain]?.integration_type === "system"; + + // System domain items are grouped under the entity's real domain (if + // a specific entity is selected), so they appear alongside that domain's + // own items rather than in a separate section. + const groupDomain = + isSystemDomain && targetEntityDomain ? targetEntityDomain : itemDomain; + + if (!items[groupDomain]) { + items[groupDomain] = { + title: domainToName( + localize, + groupDomain, + this._manifests?.[groupDomain] + ), items: [], }; } - items[domain].items.push( - this._getTriggerListItem(localize, domain, trigger) - ); + items[groupDomain].items.push(getListItem(localize, itemDomain, id)); - items[domain].items.sort((a, b) => + items[groupDomain].items.sort((a, b) => stringCompare(a.name, b.name, this.hass.locale.language) ); }); @@ -1747,39 +1918,6 @@ class DialogAddAutomationElement ); } - private _getDomainGroupedConditionListItems( - localize: LocalizeFunc, - conditionIds: string[] - ): { title: string; items: AddAutomationElementListItem[] }[] { - const items: Record< - string, - { title: string; items: AddAutomationElementListItem[] } - > = {}; - - conditionIds.forEach((condition) => { - const domain = getConditionDomain(condition); - if (!items[domain]) { - items[domain] = { - title: domainToName(localize, domain, this._manifests?.[domain]), - items: [], - }; - } - - items[domain].items.push( - this._getConditionListItem(localize, domain, condition) - ); - - items[domain].items.sort((a, b) => - stringCompare(a.name, b.name, this.hass.locale.language) - ); - }); - - return this._sortDomainsByCollection( - this._params!.type, - Object.entries(items) - ); - } - // #endregion render prepare // #region interaction @@ -1909,9 +2047,11 @@ class DialogAddAutomationElement this._selectedTarget ); - const grouped = this._getDomainGroupedTriggerListItems( + const grouped = this._getDomainGroupedListItems( this.hass.localize, - items + items, + getTriggerDomain, + this._getTriggerListItem ); if (this._selectedTarget.entity_id) { grouped.push({ @@ -1930,9 +2070,11 @@ class DialogAddAutomationElement this._selectedTarget ); - const grouped = this._getDomainGroupedConditionListItems( + const grouped = this._getDomainGroupedListItems( this.hass.localize, - items + items, + getConditionDomain, + this._getConditionListItem ); if (this._selectedTarget.entity_id) { grouped.push({