diff --git a/src/components/ha-condition-icon.ts b/src/components/ha-condition-icon.ts new file mode 100644 index 000000000000..f354b5afd9b1 --- /dev/null +++ b/src/components/ha-condition-icon.ts @@ -0,0 +1,84 @@ +import { + mdiAmpersand, + mdiClockOutline, + mdiCodeBraces, + mdiDevices, + mdiGateOr, + mdiIdentifier, + mdiMapMarkerRadius, + mdiNotEqualVariant, + mdiNumeric, + mdiStateMachine, + mdiWeatherSunny, +} from "@mdi/js"; +import { html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; +import { until } from "lit/directives/until"; +import { computeDomain } from "../common/entity/compute_domain"; +import { conditionIcon, FALLBACK_DOMAIN_ICONS } from "../data/icons"; +import type { HomeAssistant } from "../types"; +import "./ha-icon"; +import "./ha-svg-icon"; + +export const CONDITION_ICONS = { + device: mdiDevices, + and: mdiAmpersand, + or: mdiGateOr, + not: mdiNotEqualVariant, + state: mdiStateMachine, + numeric_state: mdiNumeric, + sun: mdiWeatherSunny, + template: mdiCodeBraces, + time: mdiClockOutline, + trigger: mdiIdentifier, + zone: mdiMapMarkerRadius, +}; + +@customElement("ha-condition-icon") +export class HaConditionIcon extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public condition?: string; + + @property() public icon?: string; + + protected render() { + if (this.icon) { + return html``; + } + + if (!this.condition) { + return nothing; + } + + if (!this.hass) { + return this._renderFallback(); + } + + const icon = conditionIcon(this.hass, this.condition).then((icn) => { + if (icn) { + return html``; + } + return this._renderFallback(); + }); + + return html`${until(icon)}`; + } + + private _renderFallback() { + const domain = computeDomain(this.condition!); + + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-condition-icon": HaConditionIcon; + } +} diff --git a/src/data/automation.ts b/src/data/automation.ts index a19e149706bc..d544978630af 100644 --- a/src/data/automation.ts +++ b/src/data/automation.ts @@ -10,6 +10,7 @@ import type { LocalizeKeys } from "../common/translations/localize"; import { createSearchParam } from "../common/url/search-params"; import type { Context, HomeAssistant } from "../types"; import type { BlueprintInput } from "./blueprint"; +import type { ConditionDescription } from "./condition"; import { CONDITION_BUILDING_BLOCKS } from "./condition"; import type { DeviceCondition, DeviceTrigger } from "./device_automation"; import type { Action, Field, MODES } from "./script"; @@ -236,6 +237,12 @@ interface BaseCondition { condition: string; alias?: string; enabled?: boolean; + options?: Record; +} + +export interface PlatformCondition extends BaseCondition { + condition: Exclude; + target?: HassServiceTarget; } export interface LogicalCondition extends BaseCondition { @@ -320,7 +327,7 @@ export type AutomationElementGroup = Record< { icon?: string; members?: AutomationElementGroup } >; -export type Condition = +export type LegacyCondition = | StateCondition | NumericStateCondition | SunCondition @@ -331,6 +338,8 @@ export type Condition = | LogicalCondition | TriggerCondition; +export type Condition = LegacyCondition | PlatformCondition; + export type ConditionWithShorthand = | Condition | ShorthandAndConditionList @@ -608,6 +617,7 @@ export interface ConditionSidebarConfig extends BaseSidebarConfig { insertAfter: (value: Condition | Condition[]) => boolean; toggleYamlMode: () => void; config: Condition; + description?: ConditionDescription; yamlMode: boolean; uiSupported: boolean; } diff --git a/src/data/automation_i18n.ts b/src/data/automation_i18n.ts index fb8413dfd7b8..da42eebbbbf0 100644 --- a/src/data/automation_i18n.ts +++ b/src/data/automation_i18n.ts @@ -18,7 +18,14 @@ import { } from "../common/string/format-list"; import { hasTemplate } from "../common/string/has-template"; import type { HomeAssistant } from "../types"; -import type { Condition, ForDict, LegacyTrigger, Trigger } from "./automation"; +import type { + Condition, + ForDict, + LegacyCondition, + LegacyTrigger, + Trigger, +} from "./automation"; +import { getConditionDomain, getConditionObjectId } from "./condition"; import type { DeviceCondition, DeviceTrigger } from "./device_automation"; import { localizeDeviceAutomationCondition, @@ -896,6 +903,39 @@ const tryDescribeCondition = ( } } + const description = describeLegacyCondition( + condition as LegacyCondition, + hass, + entityRegistry + ); + + if (description) { + return description; + } + + const conditionType = condition.condition; + + const domain = getConditionDomain(condition.condition); + const type = getConditionObjectId(condition.condition); + + return ( + hass.localize( + `component.${domain}.conditions.${type}.description_configured` + ) || + hass.localize( + `ui.panel.config.automation.editor.conditions.type.${conditionType as LegacyCondition["condition"]}.label` + ) || + hass.localize( + `ui.panel.config.automation.editor.conditions.unknown_condition` + ) + ); +}; + +const describeLegacyCondition = ( + condition: LegacyCondition, + hass: HomeAssistant, + entityRegistry: EntityRegistryEntry[] +) => { if (condition.condition === "or") { const conditions = ensureArray(condition.conditions); @@ -1287,12 +1327,5 @@ const tryDescribeCondition = ( ); } - return ( - hass.localize( - `ui.panel.config.automation.editor.conditions.type.${condition.condition}.label` - ) || - hass.localize( - `ui.panel.config.automation.editor.conditions.unknown_condition` - ) - ); + return undefined; }; diff --git a/src/data/condition.ts b/src/data/condition.ts index 7f93eaf9913a..b642b691e5d0 100644 --- a/src/data/condition.ts +++ b/src/data/condition.ts @@ -1,38 +1,15 @@ -import { - mdiAmpersand, - mdiClockOutline, - mdiCodeBraces, - mdiDevices, - mdiGateOr, - mdiIdentifier, - mdiMapClock, - mdiMapMarkerRadius, - mdiNotEqualVariant, - mdiNumeric, - mdiShape, - mdiStateMachine, - mdiWeatherSunny, -} from "@mdi/js"; +import { mdiMapClock, mdiShape } from "@mdi/js"; +import { computeDomain } from "../common/entity/compute_domain"; +import { computeObjectId } from "../common/entity/compute_object_id"; +import type { HomeAssistant } from "../types"; import type { AutomationElementGroupCollection } from "./automation"; - -export const CONDITION_ICONS = { - device: mdiDevices, - and: mdiAmpersand, - or: mdiGateOr, - not: mdiNotEqualVariant, - state: mdiStateMachine, - numeric_state: mdiNumeric, - sun: mdiWeatherSunny, - template: mdiCodeBraces, - time: mdiClockOutline, - trigger: mdiIdentifier, - zone: mdiMapMarkerRadius, -}; +import type { Selector, TargetSelector } from "./selector"; export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [ { groups: { device: {}, + dynamicGroups: {}, entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } }, time_location: { icon: mdiMapClock, @@ -62,3 +39,33 @@ export const COLLAPSIBLE_CONDITION_ELEMENTS = [ "ha-automation-condition-not", "ha-automation-condition-or", ]; + +export interface ConditionDescription { + target?: TargetSelector["target"]; + fields: Record< + string, + { + example?: string | boolean | number; + default?: unknown; + required?: boolean; + selector?: Selector; + context?: Record; + } + >; +} + +export type ConditionDescriptions = Record; + +export const subscribeConditions = ( + hass: HomeAssistant, + callback: (conditions: ConditionDescriptions) => void +) => + hass.connection.subscribeMessage(callback, { + type: "condition_platforms/subscribe", + }); + +export const getConditionDomain = (condition: string) => + condition.includes(".") ? computeDomain(condition) : condition; + +export const getConditionObjectId = (condition: string) => + condition.includes(".") ? computeObjectId(condition) : "_"; diff --git a/src/data/icons.ts b/src/data/icons.ts index 7e2371c48894..eb16fbbb8c31 100644 --- a/src/data/icons.ts +++ b/src/data/icons.ts @@ -60,6 +60,7 @@ import type { import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg"; import { getTriggerDomain, getTriggerObjectId } from "./trigger"; +import { getConditionDomain, getConditionObjectId } from "./condition"; /** Icon to use when no icon specified for service. */ export const DEFAULT_SERVICE_ICON = mdiRoomService; @@ -138,15 +139,25 @@ const resources: { all?: Promise>; domains: Record>; }; + conditions: { + all?: Promise>; + domains: Record>; + }; } = { entity: {}, entity_component: {}, services: { domains: {} }, triggers: { domains: {} }, + conditions: { domains: {} }, }; interface IconResources< - T extends ComponentIcons | PlatformIcons | ServiceIcons | TriggerIcons, + T extends + | ComponentIcons + | PlatformIcons + | ServiceIcons + | TriggerIcons + | ConditionIcons, > { resources: Record; } @@ -195,17 +206,24 @@ type TriggerIcons = Record< { trigger: string; sections?: Record } >; +type ConditionIcons = Record< + string, + { condition: string; sections?: Record } +>; + export type IconCategory = | "entity" | "entity_component" | "services" - | "triggers"; + | "triggers" + | "conditions"; interface CategoryType { entity: PlatformIcons; entity_component: ComponentIcons; services: ServiceIcons; triggers: TriggerIcons; + conditions: ConditionIcons; } export const getHassIcons = async ( @@ -327,6 +345,13 @@ export const getTriggerIcons = async ( ): Promise | undefined> => getCategoryIcons(hass, "triggers", domain, force); +export const getConditionIcons = async ( + hass: HomeAssistant, + domain?: string, + force = false +): Promise | undefined> => + getCategoryIcons(hass, "conditions", domain, force); + // Cache for sorted range keys const sortedRangeCache = new WeakMap, number[]>(); @@ -526,6 +551,25 @@ export const triggerIcon = async ( return icon; }; +export const conditionIcon = async ( + hass: HomeAssistant, + condition: string +): Promise => { + let icon: string | undefined; + + const domain = getConditionDomain(condition); + const conditionIcons = await getConditionIcons(hass, domain); + if (conditionIcons) { + const conditionName = getConditionObjectId(condition); + const condIcon = conditionIcons[conditionName] as ConditionIcons[string]; + icon = condIcon?.condition; + } + if (!icon) { + icon = await domainIcon(hass, domain); + } + return icon; +}; + export const serviceIcon = async ( hass: HomeAssistant, service: string diff --git a/src/data/translation.ts b/src/data/translation.ts index b6b9ef24e709..9e159dd0d125 100644 --- a/src/data/translation.ts +++ b/src/data/translation.ts @@ -75,7 +75,8 @@ export type TranslationCategory = | "preview_features" | "selector" | "services" - | "triggers"; + | "triggers" + | "conditions"; export const subscribeTranslationPreferences = ( hass: HomeAssistant, diff --git a/src/panels/config/automation/action/types/ha-automation-action-condition.ts b/src/panels/config/automation/action/types/ha-automation-action-condition.ts index ca7357bc4ced..92a682fcbc9e 100644 --- a/src/panels/config/automation/action/types/ha-automation-action-condition.ts +++ b/src/panels/config/automation/action/types/ha-automation-action-condition.ts @@ -1,19 +1,29 @@ import { css, html, LitElement, nothing } from "lit"; -import { customElement, property, query } from "lit/decorators"; +import { customElement, property, query, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../../../../../common/dom/fire_event"; -import { stringCompare } from "../../../../../common/string/compare"; import { stopPropagation } from "../../../../../common/dom/stop_propagation"; +import { stringCompare } from "../../../../../common/string/compare"; import type { LocalizeFunc } from "../../../../../common/translations/localize"; +import { CONDITION_ICONS } from "../../../../../components/ha-condition-icon"; import "../../../../../components/ha-list-item"; import "../../../../../components/ha-select"; import type { HaSelect } from "../../../../../components/ha-select"; -import type { Condition } from "../../../../../data/automation"; +import { + DYNAMIC_PREFIX, + getValueFromDynamic, + isDynamic, + type Condition, +} from "../../../../../data/automation"; +import type { ConditionDescriptions } from "../../../../../data/condition"; import { CONDITION_BUILDING_BLOCKS, - CONDITION_ICONS, + getConditionDomain, + getConditionObjectId, + subscribeConditions, } from "../../../../../data/condition"; -import type { Entries, HomeAssistant } from "../../../../../types"; +import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin"; +import type { HomeAssistant } from "../../../../../types"; import "../../condition/ha-automation-condition-editor"; import type HaAutomationConditionEditor from "../../condition/ha-automation-condition-editor"; import "../../condition/types/ha-automation-condition-and"; @@ -30,7 +40,10 @@ import "../../condition/types/ha-automation-condition-zone"; import type { ActionElement } from "../ha-automation-action-row"; @customElement("ha-automation-action-condition") -export class HaConditionAction extends LitElement implements ActionElement { +export class HaConditionAction + extends SubscribeMixin(LitElement) + implements ActionElement +{ @property({ attribute: false }) public hass!: HomeAssistant; @property({ type: Boolean }) public disabled = false; @@ -43,6 +56,8 @@ export class HaConditionAction extends LitElement implements ActionElement { @property({ type: Boolean, attribute: "indent" }) public indent = false; + @state() private _conditionDescriptions: ConditionDescriptions = {}; + @query("ha-automation-condition-editor") private _conditionEditor?: HaAutomationConditionEditor; @@ -50,6 +65,21 @@ export class HaConditionAction extends LitElement implements ActionElement { return { condition: "state" }; } + protected hassSubscribe() { + return [ + subscribeConditions(this.hass, (conditions) => + this._addConditions(conditions) + ), + ]; + } + + private _addConditions(conditions: ConditionDescriptions) { + this._conditionDescriptions = { + ...this._conditionDescriptions, + ...conditions, + }; + } + protected render() { const buildingBlock = CONDITION_BUILDING_BLOCKS.includes( this.action.condition @@ -64,19 +94,25 @@ export class HaConditionAction extends LitElement implements ActionElement { "ui.panel.config.automation.editor.conditions.type_select" )} .disabled=${this.disabled} - .value=${this.action.condition} + .value=${this.action.condition in this._conditionDescriptions + ? `${DYNAMIC_PREFIX}${this.action.condition}` + : this.action.condition} naturalMenuWidth @selected=${this._typeChanged} @closed=${stopPropagation} > - ${this._processedTypes(this.hass.localize).map( - ([opt, label, icon]) => html` + ${this._processedTypes( + this._conditionDescriptions, + this.hass.localize + ).map( + ([opt, label, condition]) => html` - ${label} + .condition=${condition} + > + ` )} @@ -88,11 +124,14 @@ export class HaConditionAction extends LitElement implements ActionElement { ? html` @@ -102,19 +141,46 @@ export class HaConditionAction extends LitElement implements ActionElement { } private _processedTypes = memoizeOne( - (localize: LocalizeFunc): [string, string, string][] => - (Object.entries(CONDITION_ICONS) as Entries) - .map( - ([condition, icon]) => - [ - condition, - localize( - `ui.panel.config.automation.editor.conditions.type.${condition}.label` - ), - icon, - ] as [string, string, string] - ) - .sort((a, b) => stringCompare(a[1], b[1], this.hass.locale.language)) + ( + conditionDescriptions: ConditionDescriptions, + localize: LocalizeFunc + ): [string, string, string][] => { + const legacy = ( + Object.keys(CONDITION_ICONS) as (keyof typeof CONDITION_ICONS)[] + ).map( + (condition) => + [ + condition, + localize( + `ui.panel.config.automation.editor.conditions.type.${condition}.label` + ), + condition, + ] as [string, string, string] + ); + const platform = Object.keys(conditionDescriptions).map((condition) => { + const domain = getConditionDomain(condition); + const conditionObjId = getConditionObjectId(condition); + return [ + `${DYNAMIC_PREFIX}${condition}`, + localize(`component.${domain}.conditions.${conditionObjId}.name`) || + condition, + condition, + ] as [string, string, string]; + }); + return [...legacy, ...platform].sort((a, b) => + stringCompare(a[1], b[1], this.hass.locale.language) + ); + } + ); + + private _getType = memoizeOne( + (condition: Condition, conditionDescriptions: ConditionDescriptions) => { + if (condition.condition in conditionDescriptions) { + return "platform"; + } + + return condition.condition; + } ); private _conditionChanged(ev: CustomEvent) { @@ -132,6 +198,18 @@ export class HaConditionAction extends LitElement implements ActionElement { return; } + if (isDynamic(type)) { + const value = getValueFromDynamic(type); + if (value !== this.action.condition) { + fireEvent(this, "value-changed", { + value: { + condition: value, + }, + }); + } + return; + } + const elClass = customElements.get( `ha-automation-condition-${type}` ) as CustomElementConstructor & { diff --git a/src/panels/config/automation/add-automation-element-dialog.ts b/src/panels/config/automation/add-automation-element-dialog.ts index f112203f7e50..46e4a44c5514 100644 --- a/src/panels/config/automation/add-automation-element-dialog.ts +++ b/src/panels/config/automation/add-automation-element-dialog.ts @@ -56,12 +56,19 @@ import { type AutomationElementGroup, type AutomationElementGroupCollection, } from "../../../data/automation"; +import type { ConditionDescriptions } from "../../../data/condition"; import { CONDITION_BUILDING_BLOCKS_GROUP, CONDITION_COLLECTIONS, - CONDITION_ICONS, + getConditionDomain, + getConditionObjectId, + subscribeConditions, } from "../../../data/condition"; -import { getServiceIcons, getTriggerIcons } from "../../../data/icons"; +import { + getConditionIcons, + getServiceIcons, + getTriggerIcons, +} from "../../../data/icons"; import type { IntegrationManifest } from "../../../data/integration"; import { domainToName, @@ -82,6 +89,7 @@ import { isMac } from "../../../util/is_mac"; import { showToast } from "../../../util/toast"; import type { AddAutomationElementDialogParams } from "./show-add-automation-element-dialog"; import { PASTE_VALUE } from "./show-add-automation-element-dialog"; +import { CONDITION_ICONS } from "../../../components/ha-condition-icon"; const TYPES = { trigger: { collections: TRIGGER_COLLECTIONS, icons: TRIGGER_ICONS }, @@ -119,7 +127,7 @@ const ENTITY_DOMAINS_OTHER = new Set([ const ENTITY_DOMAINS_MAIN = new Set(["notify"]); -const ACTION_SERVICE_KEYWORDS = ["dynamicGroups", "helpers", "other"]; +const DYNAMIC_KEYWORDS = ["dynamicGroups", "helpers", "other"]; @customElement("add-automation-element-dialog") class DialogAddAutomationElement @@ -152,6 +160,8 @@ class DialogAddAutomationElement @state() private _triggerDescriptions: TriggerDescriptions = {}; + @state() private _conditionDescriptions: ConditionDescriptions = {}; + @query(".items ha-md-list ha-md-list-item") private _itemsListFirstElement?: HaMdList; @@ -169,15 +179,15 @@ class DialogAddAutomationElement this.addKeyboardShortcuts(); + this._unsubscribe(); + this._fetchManifests(); + if (this._params?.type === "action") { this.hass.loadBackendTranslation("services"); - this._fetchManifests(); this._calculateUsedDomains(); getServiceIcons(this.hass); - } - if (this._params?.type === "trigger") { + } else if (this._params?.type === "trigger") { this.hass.loadBackendTranslation("triggers"); - this._fetchManifests(); getTriggerIcons(this.hass); this._unsub = subscribeTriggers(this.hass, (triggers) => { this._triggerDescriptions = { @@ -185,7 +195,17 @@ class DialogAddAutomationElement ...triggers, }; }); + } else if (this._params?.type === "condition") { + this.hass.loadBackendTranslation("conditions"); + getConditionIcons(this.hass); + this._unsub = subscribeConditions(this.hass, (conditions) => { + this._conditionDescriptions = { + ...this._conditionDescriptions, + ...conditions, + }; + }); } + this._fullScreen = matchMedia( "all and (max-width: 450px), all and (max-height: 500px)" ).matches; @@ -199,10 +219,7 @@ class DialogAddAutomationElement public closeDialog() { this.removeKeyboardShortcuts(); - if (this._unsub) { - this._unsub.then((unsub) => unsub()); - this._unsub = undefined; - } + this._unsubscribe(); if (this._params) { fireEvent(this, "dialog-closed", { dialog: this.localName }); } @@ -219,6 +236,13 @@ class DialogAddAutomationElement return true; } + private _unsubscribe() { + if (this._unsub) { + this._unsub.then((unsub) => unsub()); + this._unsub = undefined; + } + } + private _getGroups = ( type: AddAutomationElementDialogParams["type"], group?: string, @@ -348,8 +372,11 @@ class DialogAddAutomationElement items.push( ...this._triggers(localize, this._triggerDescriptions, manifests) ); - } - if (type === "action") { + } else if (type === "condition") { + items.push( + ...this._conditions(localize, this._conditionDescriptions, manifests) + ); + } else if (type === "action") { items.push(...this._services(localize, services, manifests)); } return items; @@ -372,6 +399,7 @@ class DialogAddAutomationElement localize: LocalizeFunc, services: HomeAssistant["services"], triggerDescriptions: TriggerDescriptions, + conditionDescriptions: ConditionDescriptions, manifests?: DomainManifestLookup ): { titleKey?: LocalizeKeys; @@ -384,15 +412,15 @@ class DialogAddAutomationElement const groups: ListItem[] = []; if ( - type === "action" && + type === "trigger" && Object.keys(collection.groups).some((item) => - ACTION_SERVICE_KEYWORDS.includes(item) + DYNAMIC_KEYWORDS.includes(item) ) ) { groups.push( - ...this._serviceGroups( + ...this._triggerGroups( localize, - services, + triggerDescriptions, manifests, domains, collection.groups.dynamicGroups @@ -404,20 +432,41 @@ class DialogAddAutomationElement ); collectionGroups = collectionGroups.filter( - ([key]) => !ACTION_SERVICE_KEYWORDS.includes(key) + ([key]) => !DYNAMIC_KEYWORDS.includes(key) + ); + } else if ( + type === "condition" && + Object.keys(collection.groups).some((item) => + DYNAMIC_KEYWORDS.includes(item) + ) + ) { + groups.push( + ...this._conditionGroups( + localize, + conditionDescriptions, + manifests, + domains, + collection.groups.dynamicGroups + ? undefined + : collection.groups.helpers + ? "helper" + : "other" + ) ); - } - if ( - type === "trigger" && + collectionGroups = collectionGroups.filter( + ([key]) => !DYNAMIC_KEYWORDS.includes(key) + ); + } else if ( + type === "action" && Object.keys(collection.groups).some((item) => - ACTION_SERVICE_KEYWORDS.includes(item) + DYNAMIC_KEYWORDS.includes(item) ) ) { groups.push( - ...this._triggerGroups( + ...this._serviceGroups( localize, - triggerDescriptions, + services, manifests, domains, collection.groups.dynamicGroups @@ -429,7 +478,7 @@ class DialogAddAutomationElement ); collectionGroups = collectionGroups.filter( - ([key]) => !ACTION_SERVICE_KEYWORDS.includes(key) + ([key]) => !DYNAMIC_KEYWORDS.includes(key) ); } @@ -487,10 +536,6 @@ class DialogAddAutomationElement services: HomeAssistant["services"], manifests?: DomainManifestLookup ): ListItem[] => { - if (type === "action" && isDynamic(group)) { - return this._services(localize, services, manifests, group); - } - if (type === "trigger" && isDynamic(group)) { return this._triggers( localize, @@ -499,6 +544,17 @@ class DialogAddAutomationElement group ); } + if (type === "condition" && isDynamic(group)) { + return this._conditions( + localize, + this._conditionDescriptions, + manifests, + group + ); + } + if (type === "action" && isDynamic(group)) { + return this._services(localize, services, manifests, group); + } const groups = this._getGroups(type, group, collectionIndex); @@ -688,6 +744,102 @@ class DialogAddAutomationElement } ); + private _conditionGroups = ( + localize: LocalizeFunc, + conditions: ConditionDescriptions, + manifests: DomainManifestLookup | undefined, + domains: Set | undefined, + type: "helper" | "other" | undefined + ): ListItem[] => { + if (!conditions || !manifests) { + return []; + } + const result: ListItem[] = []; + const addedDomains = new Set(); + Object.keys(conditions).forEach((condition) => { + const domain = getConditionDomain(condition); + + if (addedDomains.has(domain)) { + return; + } + addedDomains.add(domain); + + 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 || ""))) + ) { + result.push({ + icon: html` + + `, + key: `${DYNAMIC_PREFIX}${domain}`, + name: domainToName(localize, domain, manifest), + description: "", + }); + } + }); + return result.sort((a, b) => + stringCompare(a.name, b.name, this.hass.locale.language) + ); + }; + + private _conditions = memoizeOne( + ( + localize: LocalizeFunc, + conditions: ConditionDescriptions, + _manifests: DomainManifestLookup | undefined, + group?: string + ): ListItem[] => { + if (!conditions) { + return []; + } + const result: ListItem[] = []; + + for (const condition of Object.keys(conditions)) { + const domain = getConditionDomain(condition); + const conditionName = getConditionObjectId(condition); + + if (group && group !== `${DYNAMIC_PREFIX}${domain}`) { + continue; + } + + result.push({ + icon: html` + + `, + key: `${DYNAMIC_PREFIX}${condition}`, + name: + localize(`component.${domain}.conditions.${conditionName}.name`) || + condition, + description: + localize( + `component.${domain}.conditions.${conditionName}.description` + ) || condition, + }); + } + return result; + } + ); + private _services = memoizeOne( ( localize: LocalizeFunc, @@ -832,6 +984,7 @@ class DialogAddAutomationElement this.hass.localize, this.hass.services, this._triggerDescriptions, + this._conditionDescriptions, this._manifests ); @@ -1136,6 +1289,7 @@ class DialogAddAutomationElement super.disconnectedCallback(); window.removeEventListener("resize", this._updateNarrow); this._removeSearchKeybindings(); + this._unsubscribe(); } private _updateNarrow = () => { diff --git a/src/panels/config/automation/condition/ha-automation-condition-editor.ts b/src/panels/config/automation/condition/ha-automation-condition-editor.ts index 6698e7b0483d..c162440519fd 100644 --- a/src/panels/config/automation/condition/ha-automation-condition-editor.ts +++ b/src/panels/config/automation/condition/ha-automation-condition-editor.ts @@ -8,11 +8,13 @@ import "../../../../components/ha-yaml-editor"; import type { HaYamlEditor } from "../../../../components/ha-yaml-editor"; import type { Condition } from "../../../../data/automation"; import { expandConditionWithShorthand } from "../../../../data/automation"; +import type { ConditionDescription } from "../../../../data/condition"; import { COLLAPSIBLE_CONDITION_ELEMENTS } from "../../../../data/condition"; import type { HomeAssistant } from "../../../../types"; import "../ha-automation-editor-warning"; import { editorStyles, indentStyle } from "../styles"; import type { ConditionElement } from "./ha-automation-condition-row"; +import "./types/ha-automation-condition-platform"; @customElement("ha-automation-condition-editor") export default class HaAutomationConditionEditor extends LitElement { @@ -35,6 +37,8 @@ export default class HaAutomationConditionEditor extends LitElement { @property({ type: Boolean, attribute: "supported" }) public uiSupported = false; + @property({ attribute: false }) public description?: ConditionDescription; + @query("ha-yaml-editor") public yamlEditor?: HaYamlEditor; @query(COLLAPSIBLE_CONDITION_ELEMENTS.join(", ")) @@ -83,16 +87,23 @@ export default class HaAutomationConditionEditor extends LitElement { ` : html`
- ${dynamicElement( - `ha-automation-condition-${condition.condition}`, - { - hass: this.hass, - condition: condition, - disabled: this.disabled, - optionsInSidebar: this.indent, - narrow: this.narrow, - } - )} + ${this.description + ? html`` + : dynamicElement( + `ha-automation-condition-${condition.condition}`, + { + hass: this.hass, + condition: condition, + disabled: this.disabled, + optionsInSidebar: this.indent, + narrow: this.narrow, + } + )}
`} diff --git a/src/panels/config/automation/condition/ha-automation-condition-row.ts b/src/panels/config/automation/condition/ha-automation-condition-row.ts index e4852a036e3e..fc11c1d73d08 100644 --- a/src/panels/config/automation/condition/ha-automation-condition-row.ts +++ b/src/panels/config/automation/condition/ha-automation-condition-row.ts @@ -32,6 +32,7 @@ import { copyToClipboard } from "../../../../common/util/copy-clipboard"; import "../../../../components/ha-automation-row"; import type { HaAutomationRow } from "../../../../components/ha-automation-row"; import "../../../../components/ha-card"; +import "../../../../components/ha-condition-icon"; import "../../../../components/ha-expansion-panel"; import "../../../../components/ha-icon-button"; import "../../../../components/ha-md-button-menu"; @@ -44,10 +45,8 @@ import type { } from "../../../../data/automation"; import { isCondition, testCondition } from "../../../../data/automation"; import { describeCondition } from "../../../../data/automation_i18n"; -import { - CONDITION_BUILDING_BLOCKS, - CONDITION_ICONS, -} from "../../../../data/condition"; +import type { ConditionDescriptions } from "../../../../data/condition"; +import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition"; import { validateConfig } from "../../../../data/config"; import { fullEntitiesContext } from "../../../../data/context"; import type { EntityRegistryEntry } from "../../../../data/entity_registry"; @@ -130,6 +129,9 @@ export default class HaAutomationConditionRow extends LitElement { @state() private _warnings?: string[]; + @property({ attribute: false }) + public conditionDescriptions: ConditionDescriptions = {}; + @property({ type: Boolean, attribute: "sidebar" }) public optionsInSidebar = false; @@ -179,11 +181,11 @@ export default class HaAutomationConditionRow extends LitElement { private _renderRow() { return html` - + .hass=${this.hass} + .condition=${this.condition.condition} + >

${capitalizeFirstLetter( describeCondition(this.condition, this.hass, this._entityReg) @@ -395,9 +397,14 @@ export default class HaAutomationConditionRow extends LitElement { ` @@ -476,7 +483,9 @@ export default class HaAutomationConditionRow extends LitElement { .hass=${this.hass} .condition=${this.condition} .disabled=${this.disabled} - .uiSupported=${this._uiSupported(this.condition.condition)} + .uiSupported=${this._uiSupported( + this._getType(this.condition, this.conditionDescriptions) + )} indent .selected=${this._selected} .narrow=${this.narrow} @@ -786,7 +795,10 @@ export default class HaAutomationConditionRow extends LitElement { cut: this._cutCondition, test: this._testCondition, config: sidebarCondition, - uiSupported: this._uiSupported(sidebarCondition.condition), + uiSupported: this._uiSupported( + this._getType(sidebarCondition, this.conditionDescriptions) + ), + description: this.conditionDescriptions[sidebarCondition.condition], yamlMode: this._yamlMode, } satisfies ConditionSidebarConfig); this._selected = true; @@ -802,6 +814,16 @@ export default class HaAutomationConditionRow extends LitElement { } } + private _getType = memoizeOne( + (condition: Condition, conditionDescriptions: ConditionDescriptions) => { + if (condition.condition in conditionDescriptions) { + return "platform"; + } + + return condition.condition; + } + ); + private _uiSupported = memoizeOne( (type: string) => customElements.get(`ha-automation-condition-${type}`) !== undefined diff --git a/src/panels/config/automation/condition/ha-automation-condition.ts b/src/panels/config/automation/condition/ha-automation-condition.ts index 755abe29da11..b1b024eec7cb 100644 --- a/src/panels/config/automation/condition/ha-automation-condition.ts +++ b/src/panels/config/automation/condition/ha-automation-condition.ts @@ -4,6 +4,7 @@ import type { PropertyValues } from "lit"; import { html, LitElement, nothing } from "lit"; import { customElement, property, queryAll, state } from "lit/decorators"; import { repeat } from "lit/directives/repeat"; +import { ensureArray } from "../../../../common/array/ensure-array"; import { storage } from "../../../../common/decorators/storage"; import { fireEvent } from "../../../../common/dom/fire_event"; import { stopPropagation } from "../../../../common/dom/stop_propagation"; @@ -12,11 +13,18 @@ import "../../../../components/ha-button"; import "../../../../components/ha-button-menu"; import "../../../../components/ha-sortable"; import "../../../../components/ha-svg-icon"; -import type { - AutomationClipboard, - Condition, +import { + getValueFromDynamic, + isDynamic, + type AutomationClipboard, + type Condition, } from "../../../../data/automation"; -import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition"; +import type { ConditionDescriptions } from "../../../../data/condition"; +import { + CONDITION_BUILDING_BLOCKS, + subscribeConditions, +} from "../../../../data/condition"; +import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; import type { HomeAssistant } from "../../../../types"; import { PASTE_VALUE, @@ -25,10 +33,9 @@ import { import { automationRowsStyles } from "../styles"; import "./ha-automation-condition-row"; import type HaAutomationConditionRow from "./ha-automation-condition-row"; -import { ensureArray } from "../../../../common/array/ensure-array"; @customElement("ha-automation-condition") -export default class HaAutomationCondition extends LitElement { +export default class HaAutomationCondition extends SubscribeMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public conditions!: Condition[]; @@ -46,6 +53,8 @@ export default class HaAutomationCondition extends LitElement { @state() private _rowSortSelected?: number; + @state() private _conditionDescriptions: ConditionDescriptions = {}; + @state() @storage({ key: "automationClipboard", @@ -64,6 +73,26 @@ export default class HaAutomationCondition extends LitElement { private _conditionKeys = new WeakMap(); + protected hassSubscribe() { + return [ + subscribeConditions(this.hass, (conditions) => + this._addConditions(conditions) + ), + ]; + } + + private _addConditions(conditions: ConditionDescriptions) { + this._conditionDescriptions = { + ...this._conditionDescriptions, + ...conditions, + }; + } + + protected firstUpdated(changedProps: PropertyValues) { + super.firstUpdated(changedProps); + this.hass.loadBackendTranslation("conditions"); + } + protected updated(changedProperties: PropertyValues) { if (!changedProperties.has("conditions")) { return; @@ -168,6 +197,7 @@ export default class HaAutomationCondition extends LitElement { .last=${idx === this.conditions.length - 1} .totalConditions=${this.conditions.length} .condition=${cond} + .conditionDescriptions=${this._conditionDescriptions} .disabled=${this.disabled} .narrow=${this.narrow} @duplicate=${this._duplicateCondition} @@ -237,6 +267,10 @@ export default class HaAutomationCondition extends LitElement { conditions = this.conditions.concat( deepClone(this._clipboard!.condition) ); + } else if (isDynamic(value)) { + conditions = this.conditions.concat({ + condition: getValueFromDynamic(value), + }); } else { const condition = value as Condition["condition"]; const elClass = customElements.get( diff --git a/src/panels/config/automation/condition/types/ha-automation-condition-platform.ts b/src/panels/config/automation/condition/types/ha-automation-condition-platform.ts new file mode 100644 index 000000000000..4e04bcd95379 --- /dev/null +++ b/src/panels/config/automation/condition/types/ha-automation-condition-platform.ts @@ -0,0 +1,416 @@ +import { mdiHelpCircle } from "@mdi/js"; +import type { PropertyValues } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../../../../../common/dom/fire_event"; +import { computeDomain } from "../../../../../common/entity/compute_domain"; +import "../../../../../components/ha-checkbox"; +import "../../../../../components/ha-selector/ha-selector"; +import "../../../../../components/ha-settings-row"; +import type { PlatformCondition } from "../../../../../data/automation"; +import { + getConditionDomain, + getConditionObjectId, + type ConditionDescription, +} from "../../../../../data/condition"; +import type { IntegrationManifest } from "../../../../../data/integration"; +import { fetchIntegrationManifest } from "../../../../../data/integration"; +import type { TargetSelector } from "../../../../../data/selector"; +import type { HomeAssistant } from "../../../../../types"; +import { documentationUrl } from "../../../../../util/documentation-url"; + +const showOptionalToggle = (field: ConditionDescription["fields"][string]) => + field.selector && + !field.required && + !("boolean" in field.selector && field.default); + +@customElement("ha-automation-condition-platform") +export class HaPlatformCondition extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public condition!: PlatformCondition; + + @property({ attribute: false }) public description?: ConditionDescription; + + @property({ type: Boolean }) public disabled = false; + + @state() private _checkedKeys = new Set(); + + @state() private _manifest?: IntegrationManifest; + + public static get defaultConfig(): PlatformCondition { + return { condition: "" }; + } + + protected willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); + if (!this.hasUpdated) { + this.hass.loadBackendTranslation("conditions"); + this.hass.loadBackendTranslation("selector"); + } + if (!changedProperties.has("condition")) { + return; + } + const oldValue = changedProperties.get("condition") as + | undefined + | this["condition"]; + + // Fetch the manifest if we have a condition selected and the condition domain changed. + // If no condition is selected, clear the manifest. + if (this.condition?.condition) { + const domain = getConditionDomain(this.condition.condition); + + const oldDomain = getConditionDomain(oldValue?.condition || ""); + + if (domain !== oldDomain) { + this._fetchManifest(domain); + } + } else { + this._manifest = undefined; + } + } + + protected render() { + const domain = getConditionDomain(this.condition.condition); + const conditionName = getConditionObjectId(this.condition.condition); + + const description = this.hass.localize( + `component.${domain}.conditions.${conditionName}.description` + ); + + const conditionDesc = this.description; + + const shouldRenderDataYaml = !conditionDesc?.fields; + + const hasOptional = Boolean( + conditionDesc?.fields && + Object.values(conditionDesc.fields).some((field) => + showOptionalToggle(field) + ) + ); + + return html` +
+ ${description ? html`

${description}

` : nothing} + ${this._manifest + ? html` + + ` + : nothing} +
+ ${conditionDesc && "target" in conditionDesc + ? html` + ${hasOptional + ? html`
` + : nothing} + ${this.hass.localize( + "ui.components.service-control.target" + )} + ${this.hass.localize( + "ui.components.service-control.target_secondary" + )}
` + : nothing} + ${shouldRenderDataYaml + ? html`` + : Object.entries(conditionDesc.fields).map(([fieldName, dataField]) => + this._renderField( + fieldName, + dataField, + hasOptional, + domain, + conditionName + ) + )} + `; + } + + private _targetSelector = memoizeOne( + (targetSelector: TargetSelector["target"] | null | undefined) => + targetSelector ? { target: { ...targetSelector } } : { target: {} } + ); + + private _renderField = ( + fieldName: string, + dataField: ConditionDescription["fields"][string], + hasOptional: boolean, + domain: string | undefined, + conditionName: string | undefined + ) => { + const selector = dataField?.selector ?? { text: null }; + + const showOptional = showOptionalToggle(dataField); + + return dataField.selector + ? html` + ${!showOptional + ? hasOptional + ? html`
` + : nothing + : html``} + ${this.hass.localize( + `component.${domain}.conditions.${conditionName}.fields.${fieldName}.name` + ) || conditionName} + ${this.hass.localize( + `component.${domain}.conditions.${conditionName}.fields.${fieldName}.description` + )} + +
` + : nothing; + }; + + private _generateContext( + field: ConditionDescription["fields"][string] + ): Record | undefined { + if (!field.context) { + return undefined; + } + + const context = {}; + for (const [context_key, data_key] of Object.entries(field.context)) { + context[context_key] = + data_key === "target" + ? this.condition.target + : this.condition.options?.[data_key]; + } + return context; + } + + private _dataChanged(ev: CustomEvent) { + ev.stopPropagation(); + if (ev.detail.isValid === false) { + // Don't clear an object selector that returns invalid YAML + return; + } + const key = (ev.currentTarget as any).key; + const value = ev.detail.value; + if ( + this.condition?.options?.[key] === value || + ((!this.condition?.options || !(key in this.condition.options)) && + (value === "" || value === undefined)) + ) { + return; + } + + const options = { ...this.condition?.options, [key]: value }; + + if ( + value === "" || + value === undefined || + (typeof value === "object" && !Object.keys(value).length) + ) { + delete options[key]; + } + + fireEvent(this, "value-changed", { + value: { + ...this.condition, + options, + }, + }); + } + + private _targetChanged(ev: CustomEvent): void { + ev.stopPropagation(); + fireEvent(this, "value-changed", { + value: { + ...this.condition, + target: ev.detail.value, + }, + }); + } + + private _checkboxChanged(ev) { + const checked = ev.currentTarget.checked; + const key = ev.currentTarget.key; + let options; + + if (checked) { + this._checkedKeys.add(key); + const field = + this.description && + Object.entries(this.description).find(([k, _value]) => k === key)?.[1]; + let defaultValue = field?.default; + + if ( + defaultValue == null && + field?.selector && + "constant" in field.selector + ) { + defaultValue = field.selector.constant?.value; + } + + if ( + defaultValue == null && + field?.selector && + "boolean" in field.selector + ) { + defaultValue = false; + } + + if (defaultValue != null) { + options = { + ...this.condition?.options, + [key]: defaultValue, + }; + } + } else { + this._checkedKeys.delete(key); + options = { ...this.condition?.options }; + delete options[key]; + } + if (options) { + fireEvent(this, "value-changed", { + value: { + ...this.condition, + options, + }, + }); + } + this.requestUpdate("_checkedKeys"); + } + + private _localizeValueCallback = (key: string) => { + if (!this.condition?.condition) { + return ""; + } + return this.hass.localize( + `component.${computeDomain(this.condition.condition)}.selector.${key}` + ); + }; + + private async _fetchManifest(integration: string) { + this._manifest = undefined; + try { + this._manifest = await fetchIntegrationManifest(this.hass, integration); + } catch (_err: any) { + // eslint-disable-next-line no-console + console.log(`Unable to fetch integration manifest for ${integration}`); + // Ignore if loading manifest fails. Probably bad JSON in manifest + } + } + + static styles = css` + ha-settings-row { + padding: 0 var(--ha-space-4); + } + ha-settings-row[narrow] { + padding-bottom: var(--ha-space-2); + } + ha-settings-row { + --settings-row-content-width: 100%; + --settings-row-prefix-display: contents; + border-top: var( + --service-control-items-border-top, + 1px solid var(--divider-color) + ); + } + ha-service-picker, + ha-entity-picker, + ha-yaml-editor { + display: block; + margin: 0 var(--ha-space-4); + } + ha-yaml-editor { + padding: var(--ha-space-4) 0; + } + p { + margin: 0 var(--ha-space-4); + padding: var(--ha-space-4) 0; + } + :host([hide-picker]) p { + padding-top: 0; + } + .checkbox-spacer { + width: 32px; + } + ha-checkbox { + margin-left: calc(var(--ha-space-4) * -1); + margin-inline-start: calc(var(--ha-space-4) * -1); + margin-inline-end: initial; + } + .help-icon { + color: var(--secondary-text-color); + } + .description { + justify-content: space-between; + display: flex; + align-items: center; + padding-right: 2px; + padding-inline-end: 2px; + padding-inline-start: initial; + } + .description p { + direction: ltr; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-automation-condition-platform": HaPlatformCondition; + } +} diff --git a/src/panels/config/automation/sidebar/ha-automation-sidebar-condition.ts b/src/panels/config/automation/sidebar/ha-automation-sidebar-condition.ts index ea93c98371d7..f4920fe39c8d 100644 --- a/src/panels/config/automation/sidebar/ha-automation-sidebar-condition.ts +++ b/src/panels/config/automation/sidebar/ha-automation-sidebar-condition.ts @@ -16,11 +16,16 @@ import { classMap } from "lit/directives/class-map"; import { keyed } from "lit/directives/keyed"; import { fireEvent } from "../../../../common/dom/fire_event"; import { handleStructError } from "../../../../common/structs/handle-errors"; -import { - testCondition, - type ConditionSidebarConfig, +import type { + LegacyCondition, + ConditionSidebarConfig, } from "../../../../data/automation"; -import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition"; +import { testCondition } from "../../../../data/automation"; +import { + CONDITION_BUILDING_BLOCKS, + getConditionDomain, + getConditionObjectId, +} from "../../../../data/condition"; import { validateConfig } from "../../../../data/config"; import type { HomeAssistant } from "../../../../types"; import { isMac } from "../../../../util/is_mac"; @@ -84,14 +89,25 @@ export default class HaAutomationSidebarCondition extends LitElement { "ui.panel.config.automation.editor.conditions.condition" ); + const domain = + "condition" in this.config.config && + getConditionDomain(this.config.config.condition); + const conditionName = + "condition" in this.config.config && + getConditionObjectId(this.config.config.condition); + const title = this.hass.localize( - `ui.panel.config.automation.editor.conditions.type.${type}.label` - ) || type; + `ui.panel.config.automation.editor.conditions.type.${type as LegacyCondition["condition"]}.label` + ) || + this.hass.localize( + `component.${domain}.conditions.${conditionName}.name` + ) || + type; const description = isBuildingBlock ? this.hass.localize( - `ui.panel.config.automation.editor.conditions.type.${type}.description.picker` + `ui.panel.config.automation.editor.conditions.type.${type as LegacyCondition["condition"]}.description.picker` ) : ""; @@ -282,6 +298,7 @@ export default class HaAutomationSidebarCondition extends LitElement { class="sidebar-editor" .hass=${this.hass} .condition=${this.config.config} + .description=${this.config.description} .yamlMode=${this.yamlMode} .uiSupported=${this.config.uiSupported} @value-changed=${this._valueChangedSidebar}