import { consume } from "@lit/context"; import { mdiAppleKeyboardCommand, mdiCog, mdiContentSave, mdiDebugStepOver, mdiDelete, mdiDotsVertical, mdiFileEdit, mdiInformationOutline, mdiPlay, mdiPlayCircleOutline, mdiPlaylistEdit, mdiPlusCircleMultipleOutline, mdiRedo, mdiRenameBox, mdiRobotConfused, mdiStopCircleOutline, mdiTag, mdiTransitConnection, mdiUndo, } from "@mdi/js"; import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import { css, html, LitElement, nothing } from "lit"; import { property, query, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { transform } from "../../../common/decorators/transform"; import { fireEvent } from "../../../common/dom/fire_event"; import { goBack, navigate } from "../../../common/navigate"; import { promiseTimeout } from "../../../common/util/promise-timeout"; import { afterNextRender } from "../../../common/util/render-status"; import "../../../components/ha-button"; import "../../../components/ha-button-menu"; import "../../../components/ha-fab"; import "../../../components/ha-fade-in"; import "../../../components/ha-icon"; import "../../../components/ha-icon-button"; import "../../../components/ha-list-item"; import "../../../components/ha-spinner"; import "../../../components/ha-svg-icon"; import "../../../components/ha-yaml-editor"; import type { AutomationConfig, AutomationEntity, BlueprintAutomationConfig, Condition, Trigger, } from "../../../data/automation"; import { deleteAutomation, fetchAutomationFileConfig, getAutomationEditorInitData, getAutomationStateConfig, normalizeAutomationConfig, saveAutomationConfig, showAutomationEditor, triggerAutomationActions, } from "../../../data/automation"; import { substituteBlueprint } from "../../../data/blueprint"; import { validateConfig } from "../../../data/config"; import { fullEntitiesContext } from "../../../data/context"; import { UNAVAILABLE } from "../../../data/entity"; import { type EntityRegistryEntry, updateEntityRegistryEntry, } from "../../../data/entity_registry"; import type { Action } from "../../../data/script"; import { showAlertDialog, showConfirmationDialog, } from "../../../dialogs/generic/show-dialog-box"; import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog"; import "../../../layouts/hass-subpage"; import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin"; import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin"; import { UndoRedoMixin } from "../../../mixins/undo-redo-mixin"; import { haStyle } from "../../../resources/styles"; import type { Entries, HomeAssistant, Route } from "../../../types"; import { isMac } from "../../../util/is_mac"; import { showToast } from "../../../util/toast"; import { showAssignCategoryDialog } from "../category/show-dialog-assign-category"; import "../ha-config-section"; import { showAutomationModeDialog } from "./automation-mode-dialog/show-dialog-automation-mode"; import { type EntityRegistryUpdate, showAutomationSaveDialog, } from "./automation-save-dialog/show-dialog-automation-save"; import "./blueprint-automation-editor"; import "./manual-automation-editor"; import type { HaManualAutomationEditor } from "./manual-automation-editor"; declare global { interface HTMLElementTagNameMap { "ha-automation-editor": HaAutomationEditor; } // for fire event interface HASSDomEvents { "subscribe-automation-config": { callback: (config: AutomationConfig) => void; unsub?: UnsubscribeFunc; }; "ui-mode-not-available": Error; "move-down": undefined; "move-up": undefined; duplicate: undefined; "insert-after": { value: Trigger | Condition | Action | Trigger[] | Condition[] | Action[]; }; "save-automation": undefined; } } const baseEditorMixins = PreventUnsavedMixin(KeyboardShortcutMixin(LitElement)); export class HaAutomationEditor extends UndoRedoMixin< typeof baseEditorMixins, AutomationConfig >(baseEditorMixins) { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public automationId: string | null = null; @property({ attribute: false }) public entityId: string | null = null; @property({ attribute: false }) public automations!: AutomationEntity[]; @property({ attribute: "is-wide", type: Boolean }) public isWide = false; @property({ type: Boolean }) public narrow = false; @property({ attribute: false }) public route!: Route; @state() private _config?: AutomationConfig; @state() private _dirty = false; @state() private _errors?: string; @state() private _yamlErrors?: string; @state() private _entityId?: string; @state() private _mode: "gui" | "yaml" = "gui"; @state() private _readOnly = false; @state() private _validationErrors?: (string | TemplateResult)[]; @state() private _blueprintConfig?: BlueprintAutomationConfig; @state() @consume({ context: fullEntitiesContext, subscribe: true }) @transform({ transformer: function (this: HaAutomationEditor, value) { return value.find(({ entity_id }) => entity_id === this._entityId); }, watch: ["_entityId"], }) private _registryEntry?: EntityRegistryEntry; @state() private _saving = false; @state() @consume({ context: fullEntitiesContext, subscribe: true }) _entityRegistry!: EntityRegistryEntry[]; @query("manual-automation-editor") private _manualEditor?: HaManualAutomationEditor; private _configSubscriptions: Record< string, (config?: AutomationConfig) => void > = {}; private _configSubscriptionsId = 1; private _entityRegistryUpdate?: EntityRegistryUpdate; private _newAutomationId?: string; private _entityRegCreated?: ( value: PromiseLike | EntityRegistryEntry ) => void; protected willUpdate(changedProps) { super.willUpdate(changedProps); if ( this._entityRegCreated && this._newAutomationId && changedProps.has("_entityRegistry") ) { const automation = this._entityRegistry.find( (entity: EntityRegistryEntry) => entity.platform === "automation" && entity.unique_id === this._newAutomationId ); if (automation) { this._entityRegCreated(automation); this._entityRegCreated = undefined; } } } protected render(): TemplateResult | typeof nothing { if (!this._config) { return html` `; } const stateObj = this._entityId ? this.hass.states[this._entityId] : undefined; const useBlueprint = "use_blueprint" in this._config; const shortcutIcon = isMac ? html`` : this.hass.localize("ui.panel.config.automation.editor.ctrl"); return html` ${this._mode === "gui" && !this.narrow ? html` ${this.hass.localize("ui.common.undo")} ( ${shortcutIcon} + Z) ${this.hass.localize("ui.common.redo")} ( ${isMac ? html`${shortcutIcon} + Shift + Z` : html`${shortcutIcon} + Y`}) ` : nothing} ${this._config?.id && !this.narrow ? html` ${this.hass.localize( "ui.panel.config.automation.editor.show_trace" )} ` : ""} ${this._mode === "gui" && this.narrow ? html` ${this.hass.localize("ui.common.undo")} ${this.hass.localize("ui.common.redo")} ` : nothing} ${this.hass.localize("ui.panel.config.automation.editor.show_info")} ${this.hass.localize( "ui.panel.config.automation.picker.show_settings" )} ${this.hass.localize( `ui.panel.config.scene.picker.${this._registryEntry?.categories?.automation ? "edit_category" : "assign_category"}` )} ${this.hass.localize("ui.panel.config.automation.editor.run")} ${stateObj && this.narrow ? html` ${this.hass.localize( "ui.panel.config.automation.editor.show_trace" )} ` : nothing} ${this.hass.localize("ui.panel.config.automation.editor.rename")} ${!useBlueprint ? html` ${this.hass.localize( "ui.panel.config.automation.editor.change_mode" )} ` : nothing} ${this.hass.localize( this._readOnly ? "ui.panel.config.automation.editor.migrate" : "ui.panel.config.automation.editor.duplicate" )} ${useBlueprint ? html` ${this.hass.localize( "ui.panel.config.automation.editor.take_control" )} ` : nothing} ${this.hass.localize( `ui.panel.config.automation.editor.edit_${this._mode === "gui" ? "yaml" : "ui"}` )}
  • ${stateObj?.state === "off" ? this.hass.localize("ui.panel.config.automation.editor.enable") : this.hass.localize("ui.panel.config.automation.editor.disable")} ${this.hass.localize("ui.panel.config.automation.picker.delete")}
    ${this._mode === "gui" ? html`
    ${useBlueprint ? html` ` : html`
    ${this._errors || stateObj?.state === UNAVAILABLE ? html` ${this._errors || this._validationErrors} ${stateObj?.state === UNAVAILABLE ? html`` : nothing} ` : nothing} ${this._blueprintConfig ? html` ${this.hass.localize( "ui.panel.config.automation.editor.confirm_take_control" )}
    ${this.hass.localize( "ui.common.yes" )} ${this.hass.localize( "ui.common.no" )}
    ` : this._readOnly ? html`${this.hass.localize( "ui.panel.config.automation.editor.read_only" )} ${this.hass.localize( "ui.panel.config.automation.editor.migrate" )} ` : nothing} ${stateObj?.state === "off" ? html` ${this.hass.localize( "ui.panel.config.automation.editor.disabled" )} ${this.hass.localize( "ui.panel.config.automation.editor.enable" )} ` : nothing}
    `}
    ` : this._mode === "yaml" ? html`${stateObj?.state === "off" ? html` ${this.hass.localize( "ui.panel.config.automation.editor.disabled" )} ${this.hass.localize( "ui.panel.config.automation.editor.enable" )} ` : nothing} ` : nothing}
    `; } protected updated(changedProps: PropertyValues): void { super.updated(changedProps); const oldAutomationId = changedProps.get("automationId"); if ( changedProps.has("automationId") && this.automationId && this.hass && // Only refresh config if we picked a new automation. If same ID, don't fetch it. oldAutomationId !== this.automationId ) { this._setEntityId(); this._loadConfig(); } if ( changedProps.has("automationId") && !this.automationId && !this.entityId && this.hass ) { const initData = getAutomationEditorInitData(); this._dirty = !!initData; let baseConfig: Partial = { description: "" }; if (!initData || !("use_blueprint" in initData)) { baseConfig = { ...baseConfig, mode: "single", triggers: [], conditions: [], actions: [], }; } this._config = { ...baseConfig, ...(initData ? normalizeAutomationConfig(initData) : initData), } as AutomationConfig; this._entityId = undefined; this._readOnly = false; } if (changedProps.has("entityId") && this.entityId) { getAutomationStateConfig(this.hass, this.entityId).then((c) => { this._config = normalizeAutomationConfig(c.config); this._checkValidation(); }); this._entityId = this.entityId; this._dirty = false; this._readOnly = true; } if ( changedProps.has("automations") && this.automationId && !this._entityId ) { this._setEntityId(); } if (changedProps.has("_config")) { Object.values(this._configSubscriptions).forEach((sub) => sub(this._config) ); } } private _setEntityId() { const automation = this.automations.find( (entity: AutomationEntity) => entity.attributes.id === this.automationId ); this._entityId = automation?.entity_id; } private async _checkValidation() { this._validationErrors = undefined; if (!this._entityId || !this._config) { return; } const stateObj = this.hass.states[this._entityId]; if (stateObj?.state !== UNAVAILABLE) { return; } const validation = await validateConfig(this.hass, { triggers: this._config.triggers, conditions: this._config.conditions, actions: this._config.actions, }); this._validationErrors = ( Object.entries(validation) as Entries ).map(([key, value]) => value.valid ? "" : html`${this.hass.localize( `ui.panel.config.automation.editor.${key}.name` )}: ${value.error}
    ` ); } private async _loadConfig() { try { const config = await fetchAutomationFileConfig( this.hass, this.automationId as string ); this._dirty = false; this._readOnly = false; this._config = normalizeAutomationConfig(config); this._checkValidation(); } catch (err: any) { const entity = this._entityRegistry.find( (ent) => ent.platform === "automation" && ent.unique_id === this.automationId ); if (entity) { navigate(`/config/automation/show/${entity.entity_id}`, { replace: true, }); return; } await showAlertDialog(this, { text: err.status_code === 404 ? this.hass.localize( "ui.panel.config.automation.editor.load_error_not_editable" ) : this.hass.localize( "ui.panel.config.automation.editor.load_error_unknown", { err_no: err.status_code } ), }); goBack("/config"); } } private _valueChanged(ev: CustomEvent<{ value: AutomationConfig }>) { ev.stopPropagation(); if (this._config) { this.pushToUndo(this._config); } this._config = ev.detail.value; if (this._readOnly) { return; } this._dirty = true; this._errors = undefined; } private _showInfo() { if (!this.hass || !this._entityId) { return; } fireEvent(this, "hass-more-info", { entityId: this._entityId }); } private _showSettings() { showMoreInfoDialog(this, { entityId: this._entityId!, view: "settings", }); } private _editCategory() { if (!this._registryEntry) { showAlertDialog(this, { title: this.hass.localize( "ui.panel.config.scene.picker.no_category_support" ), text: this.hass.localize( "ui.panel.config.scene.picker.no_category_entity_reg" ), }); return; } showAssignCategoryDialog(this, { scope: "automation", entityReg: this._registryEntry, }); } private async _showTrace() { if (this._config?.id) { const result = await this._confirmUnsavedChanged(); if (result) { navigate( `/config/automation/trace/${encodeURIComponent(this._config.id)}` ); } } } private _runActions() { if (!this.hass || !this._entityId) { return; } triggerAutomationActions( this.hass, this.hass.states[this._entityId].entity_id ); } private async _toggle(): Promise { if (!this.hass || !this._entityId) { return; } const stateObj = this.hass.states[this._entityId]; const service = stateObj.state === "off" ? "turn_on" : "turn_off"; await this.hass.callService("automation", service, { entity_id: stateObj.entity_id, }); } private _preprocessYaml() { if (!this._config) { return {}; } const cleanConfig: AutomationConfig = { ...this._config }; delete cleanConfig.id; return cleanConfig; } private _yamlChanged(ev: CustomEvent) { ev.stopPropagation(); this._dirty = true; if (!ev.detail.isValid) { this._yamlErrors = ev.detail.errorMsg; return; } this._yamlErrors = undefined; this._config = { id: this._config?.id, ...normalizeAutomationConfig(ev.detail.value), }; this._errors = undefined; } private async _confirmUnsavedChanged(): Promise { if (!this._dirty) { return true; } return new Promise((resolve) => { showAutomationSaveDialog(this, { config: this._config!, domain: "automation", updateConfig: async (config, entityRegistryUpdate) => { this._config = config; this._entityRegistryUpdate = entityRegistryUpdate; this._dirty = true; this.requestUpdate(); const id = this.automationId || String(Date.now()); try { await this._saveAutomation(id); } catch (_err: any) { this.requestUpdate(); resolve(false); return; } resolve(true); }, onClose: () => resolve(false), onDiscard: () => resolve(true), entityRegistryUpdate: this._entityRegistryUpdate, entityRegistryEntry: this._registryEntry, title: this.hass.localize( this.automationId ? "ui.panel.config.automation.editor.leave.unsaved_confirm_title" : "ui.panel.config.automation.editor.leave.unsaved_new_title" ), description: this.hass.localize( this.automationId ? "ui.panel.config.automation.editor.leave.unsaved_confirm_text" : "ui.panel.config.automation.editor.leave.unsaved_new_text" ), hideInputs: this.automationId !== null, }); }); } private _backTapped = async () => { const result = await this._confirmUnsavedChanged(); if (result) { afterNextRender(() => goBack("/config")); } }; private async _takeControl() { const config = this._config as BlueprintAutomationConfig; try { const result = await substituteBlueprint( this.hass, "automation", config.use_blueprint.path, config.use_blueprint.input || {} ); const newConfig = { ...normalizeAutomationConfig(result.substituted_config), id: config.id, alias: config.alias, description: config.description, }; this._blueprintConfig = config; this._config = newConfig; if (this._mode === "yaml") { this.renderRoot.querySelector("ha-yaml-editor")?.setValue(this._config); } this._readOnly = true; this._errors = undefined; } catch (err: any) { this._errors = err.message; } } private _revertBlueprint() { this._config = this._blueprintConfig; if (this._mode === "yaml") { this.renderRoot.querySelector("ha-yaml-editor")?.setValue(this._config); } this._blueprintConfig = undefined; this._readOnly = false; } private _takeControlSave() { this._readOnly = false; this._dirty = true; this._blueprintConfig = undefined; } private async _duplicate() { const result = this._readOnly ? await showConfirmationDialog(this, { title: this.hass.localize( "ui.panel.config.automation.picker.migrate_automation" ), text: this.hass.localize( "ui.panel.config.automation.picker.migrate_automation_description" ), }) : await this._confirmUnsavedChanged(); if (result) { showAutomationEditor({ ...this._config, id: undefined, alias: this._readOnly ? this._config?.alias : undefined, }); } } private async _deleteConfirm() { showConfirmationDialog(this, { title: this.hass.localize( "ui.panel.config.automation.picker.delete_confirm_title" ), text: this.hass.localize( "ui.panel.config.automation.picker.delete_confirm_text", { name: this._config?.alias } ), confirmText: this.hass!.localize("ui.common.delete"), destructive: true, dismissText: this.hass!.localize("ui.common.cancel"), confirm: () => this._delete(), }); } private async _delete() { if (this.automationId) { await deleteAutomation(this.hass, this.automationId); goBack("/config"); } } private async _switchUiMode() { if (this._yamlErrors) { const result = await showConfirmationDialog(this, { text: html`${this.hass.localize( "ui.panel.config.automation.editor.switch_ui_yaml_error" )}

    ${this._yamlErrors}`, confirmText: this.hass!.localize("ui.common.continue"), destructive: true, dismissText: this.hass!.localize("ui.common.cancel"), }); if (!result) { return; } } this._yamlErrors = undefined; this._mode = "gui"; } private _switchYamlMode() { this._mode = "yaml"; } private async _promptAutomationAlias(): Promise { return new Promise((resolve) => { showAutomationSaveDialog(this, { config: this._config!, domain: "automation", updateConfig: async (config, entityRegistryUpdate) => { this._config = config; this._entityRegistryUpdate = entityRegistryUpdate; this._dirty = true; this.requestUpdate(); resolve(true); }, onClose: () => resolve(false), entityRegistryUpdate: this._entityRegistryUpdate, entityRegistryEntry: this._registryEntry, }); }); } private async _promptAutomationMode(): Promise { return new Promise((resolve) => { showAutomationModeDialog(this, { config: this._config!, updateConfig: (config) => { this._config = config; this._dirty = true; this.requestUpdate(); resolve(); }, onClose: () => resolve(), }); }); } private async _handleSaveAutomation(): Promise { if (this._yamlErrors) { showToast(this, { message: this._yamlErrors, }); return; } this._manualEditor?.resetPastedConfig(); const id = this.automationId || String(Date.now()); if (!this.automationId) { const saved = await this._promptAutomationAlias(); if (!saved) { return; } } await this._saveAutomation(id); if (!this.automationId) { navigate(`/config/automation/edit/${id}`, { replace: true }); } } private async _saveAutomation(id): Promise { this._saving = true; this._validationErrors = undefined; let entityRegPromise: Promise | undefined; if (this._entityRegistryUpdate !== undefined && !this._entityId) { this._newAutomationId = id; entityRegPromise = new Promise((resolve) => { this._entityRegCreated = resolve; }); } try { await saveAutomationConfig(this.hass, id, this._config!); if (this._entityRegistryUpdate !== undefined) { let entityId = this._entityId; // wait for automation to appear in entity registry when creating a new automation if (entityRegPromise) { try { const automation = await promiseTimeout(5000, entityRegPromise); entityId = automation.entity_id; } catch (e) { if (e instanceof Error && e.name === "TimeoutError") { showAlertDialog(this, { title: this.hass.localize( "ui.panel.config.automation.editor.new_automation_setup_failed_title", { type: this.hass.localize( "ui.panel.config.automation.editor.type_automation" ), } ), text: this.hass.localize( "ui.panel.config.automation.editor.new_automation_setup_failed_text", { type: this.hass.localize( "ui.panel.config.automation.editor.type_automation" ), types: this.hass.localize( "ui.panel.config.automation.editor.type_automation_plural" ), } ), warning: true, }); } else { throw e; } } } if (entityId) { await updateEntityRegistryEntry(this.hass, entityId, { categories: { automation: this._entityRegistryUpdate.category || null, }, labels: this._entityRegistryUpdate.labels || [], area_id: this._entityRegistryUpdate.area || null, }); } } this._dirty = false; } catch (errors: any) { this._errors = errors.body?.message || errors.error || errors.body; showToast(this, { message: errors.body?.message || errors.error || errors.body, }); throw errors; } finally { this._saving = false; } } private _subscribeAutomationConfig(ev) { const id = this._configSubscriptionsId++; this._configSubscriptions[id] = ev.detail.callback; ev.detail.unsub = () => { delete this._configSubscriptions[id]; }; ev.detail.callback(this._config); } protected supportedShortcuts(): SupportedShortcuts { return { s: () => this._handleSaveAutomation(), c: () => this._copySelectedRow(), x: () => this._cutSelectedRow(), Delete: () => this._deleteSelectedRow(), Backspace: () => this._deleteSelectedRow(), z: () => this.undo(), Z: () => this.redo(), y: () => this.redo(), }; } protected get isDirty() { return this._dirty; } protected async promptDiscardChanges() { return this._confirmUnsavedChanged(); } // @ts-ignore private _collapseAll() { this._manualEditor?.collapseAll(); } // @ts-ignore private _expandAll() { this._manualEditor?.expandAll(); } private _copySelectedRow() { this._manualEditor?.copySelectedRow(); } private _cutSelectedRow() { this._manualEditor?.cutSelectedRow(); } private _deleteSelectedRow() { this._manualEditor?.deleteSelectedRow(); } protected get currentConfig() { return this._config; } protected applyUndoRedo(config: AutomationConfig) { this._manualEditor?.triggerCloseSidebar(); this._config = config; this._dirty = true; } static get styles(): CSSResultGroup { return [ haStyle, css` :host { --ha-automation-editor-max-width: var( --ha-automation-editor-width, 1540px ); --hass-subpage-bottom-inset: 0px; } ha-fade-in { display: flex; justify-content: center; align-items: center; height: 100%; } .yaml-mode { height: 100%; display: flex; flex-direction: column; padding-bottom: 0; } manual-automation-editor, blueprint-automation-editor { margin: 0 auto; max-width: 1040px; padding: 28px 20px 0; display: block; } manual-automation-editor { max-width: var(--ha-automation-editor-max-width); padding: 0 12px; } ha-yaml-editor { flex-grow: 1; --actions-border-radius: 0; --code-mirror-height: 100%; min-height: 0; display: flex; flex-direction: column; } p { margin-bottom: 0; } ha-entity-toggle { margin-right: 8px; margin-inline-end: 8px; margin-inline-start: initial; } li[role="separator"] { border-bottom-color: var(--divider-color); } ha-button-menu a { text-decoration: none; color: var(--primary-color); } h1 { margin: 0; } .header-name { display: flex; align-items: center; margin: 0 auto; max-width: 1040px; padding: 28px 20px 0; } ha-fab { position: fixed; right: calc(16px + var(--safe-area-inset-right, 0px)); bottom: calc(-80px - var(--safe-area-inset-bottom)); transition: bottom 0.3s; } ha-fab.dirty { bottom: calc(16px + var(--safe-area-inset-bottom, 0px)); } ha-tooltip ha-svg-icon { width: 12px; } ha-tooltip .shortcut { display: inline-flex; flex-direction: row; align-items: center; gap: 2px; } `, ]; } } customElements.define("ha-automation-editor", HaAutomationEditor);