diff --git a/src/data/automation.ts b/src/data/automation.ts index 71a7e549c2..6359264eac 100644 --- a/src/data/automation.ts +++ b/src/data/automation.ts @@ -5,6 +5,7 @@ import { import { HomeAssistant } from "../types"; import { navigate } from "../common/navigate"; import { DeviceCondition, DeviceTrigger } from "./device_automation"; +import { Action } from "./script"; export interface AutomationEntity extends HassEntityBase { attributes: HassEntityAttributeBase & { @@ -18,7 +19,7 @@ export interface AutomationConfig { description: string; trigger: Trigger[]; condition?: Condition[]; - action: any[]; + action: Action[]; } export interface ForDict { diff --git a/src/data/script.ts b/src/data/script.ts index 5eb2982f2f..a4e5055acc 100644 --- a/src/data/script.ts +++ b/src/data/script.ts @@ -1,5 +1,6 @@ import { HomeAssistant } from "../types"; import { computeObjectId } from "../common/entity/compute_object_id"; +import { Condition } from "./automation"; export interface EventAction { event: string; @@ -7,12 +8,40 @@ export interface EventAction { event_data_template?: { [key: string]: any }; } +export interface ServiceAction { + service: string; + entity_id?: string; + data?: { [key: string]: any }; +} + export interface DeviceAction { device_id: string; domain: string; entity_id: string; } +export interface DelayAction { + delay: number; +} + +export interface SceneAction { + scene: string; +} + +export interface WaitAction { + wait_template: string; + timeout?: number; +} + +export type Action = + | EventAction + | DeviceAction + | ServiceAction + | Condition + | DelayAction + | SceneAction + | WaitAction; + export const triggerScript = ( hass: HomeAssistant, entityId: string, diff --git a/src/panels/config/automation/action/ha-automation-action-row.ts b/src/panels/config/automation/action/ha-automation-action-row.ts new file mode 100644 index 0000000000..df0b925ea8 --- /dev/null +++ b/src/panels/config/automation/action/ha-automation-action-row.ts @@ -0,0 +1,272 @@ +import "@polymer/paper-icon-button/paper-icon-button"; +import "@polymer/paper-item/paper-item"; +import "@polymer/paper-listbox/paper-listbox"; +// tslint:disable-next-line +import { PaperListboxElement } from "@polymer/paper-listbox/paper-listbox"; +import "@polymer/paper-menu-button/paper-menu-button"; +import { + css, + CSSResult, + customElement, + html, + LitElement, + property, +} from "lit-element"; +import { dynamicElement } from "../../../../common/dom/dynamic-element-directive"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-card"; +import { HomeAssistant } from "../../../../types"; + +import { Action } from "../../../../data/script"; + +import "./types/ha-automation-action-service"; +import "./types/ha-automation-action-device_id"; +import "./types/ha-automation-action-delay"; +import "./types/ha-automation-action-event"; +import "./types/ha-automation-action-condition"; +import "./types/ha-automation-action-scene"; +import "./types/ha-automation-action-wait_template"; + +const OPTIONS = [ + "condition", + "delay", + "device_id", + "event", + "scene", + "service", + "wait_template", +]; + +const getType = (action: Action) => { + return OPTIONS.find((option) => option in action); +}; + +declare global { + // for fire event + interface HASSDomEvents { + "move-action": { direction: "up" | "down" }; + } +} + +export interface ActionElement extends LitElement { + action: Action; +} + +export const handleChangeEvent = (element: ActionElement, ev: CustomEvent) => { + ev.stopPropagation(); + const name = (ev.target as any)?.name; + if (!name) { + return; + } + const newVal = ev.detail.value; + + if ((element.action[name] || "") === newVal) { + return; + } + + let newAction: Action; + if (!newVal) { + newAction = { ...element.action }; + delete newAction[name]; + } else { + newAction = { ...element.action, [name]: newVal }; + } + fireEvent(element, "value-changed", { value: newAction }); +}; + +@customElement("ha-automation-action-row") +export default class HaAutomationActionRow extends LitElement { + @property() public hass!: HomeAssistant; + @property() public action!: Action; + @property() public index!: number; + @property() public totalActions!: number; + @property() private _yamlMode = false; + + protected render() { + const type = getType(this.action); + const selected = type ? OPTIONS.indexOf(type) : -1; + const yamlMode = this._yamlMode || selected === -1; + + return html` + +
+
+ ${this.index !== 0 + ? html` + + ` + : ""} + ${this.index !== this.totalActions - 1 + ? html` + + ` + : ""} + + + + + ${yamlMode + ? this.hass.localize( + "ui.panel.config.automation.editor.edit_ui" + ) + : this.hass.localize( + "ui.panel.config.automation.editor.edit_yaml" + )} + + + ${this.hass.localize( + "ui.panel.config.automation.editor.actions.duplicate" + )} + + + ${this.hass.localize( + "ui.panel.config.automation.editor.actions.delete" + )} + + + +
+ ${yamlMode + ? html` +
+ ${selected === -1 + ? html` + ${this.hass.localize( + "ui.panel.config.automation.editor.actions.unsupported_action", + "action", + type + )} + ` + : ""} + +
+ ` + : html` + + + ${OPTIONS.map( + (opt) => html` + + ${this.hass.localize( + `ui.panel.config.automation.editor.actions.type.${opt}.label` + )} + + ` + )} + + +
+ ${dynamicElement(`ha-automation-action-${type}`, { + hass: this.hass, + action: this.action, + })} +
+ `} +
+
+ `; + } + + private _moveUp() { + fireEvent(this, "move-action", { direction: "up" }); + } + + private _moveDown() { + fireEvent(this, "move-action", { direction: "down" }); + } + + private _onDelete() { + if ( + confirm( + this.hass.localize( + "ui.panel.config.automation.editor.actions.delete_confirm" + ) + ) + ) { + fireEvent(this, "value-changed", { value: null }); + } + } + + private _typeChanged(ev: CustomEvent) { + const type = ((ev.target as PaperListboxElement)?.selectedItem as any) + ?.action; + + if (!type) { + return; + } + + if (type !== getType(this.action)) { + const elClass = customElements.get(`ha-automation-action-${type}`); + + fireEvent(this, "value-changed", { + value: { + ...elClass.defaultConfig, + }, + }); + } + } + + private _onYamlChange(ev: CustomEvent) { + ev.stopPropagation(); + fireEvent(this, "value-changed", { value: ev.detail.value }); + } + + private _switchYamlMode() { + this._yamlMode = !this._yamlMode; + } + + static get styles(): CSSResult { + return css` + .card-menu { + position: absolute; + top: 0; + right: 0; + z-index: 3; + color: var(--primary-text-color); + } + .rtl .card-menu { + right: auto; + left: 0; + } + .card-menu paper-item { + cursor: pointer; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-automation-action-row": HaAutomationActionRow; + } +} diff --git a/src/panels/config/automation/action/ha-automation-action.ts b/src/panels/config/automation/action/ha-automation-action.ts new file mode 100644 index 0000000000..ac2e45ff10 --- /dev/null +++ b/src/panels/config/automation/action/ha-automation-action.ts @@ -0,0 +1,98 @@ +import "@material/mwc-button"; +import { + css, + CSSResult, + customElement, + html, + LitElement, + property, +} from "lit-element"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-card"; +import { Action } from "../../../../data/script"; +import { HomeAssistant } from "../../../../types"; +import "./ha-automation-action-row"; + +@customElement("ha-automation-action") +export default class HaAutomationAction extends LitElement { + @property() public hass!: HomeAssistant; + @property() public actions!: Action[]; + + protected render() { + return html` + ${this.actions.map( + (action, idx) => html` + + ` + )} + +
+ + ${this.hass.localize( + "ui.panel.config.automation.editor.actions.add" + )} + +
+
+ `; + } + + private _addAction() { + const actions = this.actions.concat({ + service: "", + }); + + fireEvent(this, "value-changed", { value: actions }); + } + + private _move(ev: CustomEvent) { + const index = (ev.target as any).index; + const newIndex = ev.detail.direction === "up" ? index - 1 : index + 1; + const actions = this.actions.concat(); + const action = actions.splice(index, 1)[0]; + actions.splice(newIndex, 0, action); + fireEvent(this, "value-changed", { value: actions }); + } + + private _actionChanged(ev: CustomEvent) { + ev.stopPropagation(); + const actions = [...this.actions]; + const newValue = ev.detail.value; + const index = (ev.target as any).index; + + if (newValue === null) { + actions.splice(index, 1); + } else { + actions[index] = newValue; + } + + fireEvent(this, "value-changed", { value: actions }); + } + + static get styles(): CSSResult { + return css` + ha-automation-action-row, + ha-card { + display: block; + margin-top: 16px; + } + .add-card mwc-button { + display: block; + text-align: center; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-automation-action": HaAutomationAction; + } +} 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 new file mode 100644 index 0000000000..967455ffbb --- /dev/null +++ b/src/panels/config/automation/action/types/ha-automation-action-condition.ts @@ -0,0 +1,41 @@ +import "../../condition/ha-automation-condition-editor"; + +import { LitElement, property, customElement, html } from "lit-element"; +import { ActionElement } from "../ha-automation-action-row"; +import { HomeAssistant } from "../../../../../types"; +import { fireEvent } from "../../../../../common/dom/fire_event"; +import { Condition } from "../../../../../data/automation"; + +@customElement("ha-automation-action-condition") +export class HaConditionAction extends LitElement implements ActionElement { + @property() public hass!: HomeAssistant; + @property() public action!: Condition; + + public static get defaultConfig() { + return { condition: "state" }; + } + + public render() { + return html` + + `; + } + + private _conditionChanged(ev: CustomEvent) { + ev.stopPropagation(); + + fireEvent(this, "value-changed", { + value: ev.detail.value, + }); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-automation-action-condition": HaConditionAction; + } +} diff --git a/src/panels/config/automation/action/types/ha-automation-action-delay.ts b/src/panels/config/automation/action/types/ha-automation-action-delay.ts new file mode 100644 index 0000000000..6dd9abeafe --- /dev/null +++ b/src/panels/config/automation/action/types/ha-automation-action-delay.ts @@ -0,0 +1,44 @@ +import "@polymer/paper-input/paper-input"; +import "../../../../../components/ha-service-picker"; +import "../../../../../components/entity/ha-entity-picker"; +import "../../../../../components/ha-yaml-editor"; + +import { LitElement, property, customElement, html } from "lit-element"; +import { ActionElement, handleChangeEvent } from "../ha-automation-action-row"; +import { HomeAssistant } from "../../../../../types"; +import { DelayAction } from "../../../../../data/script"; + +@customElement("ha-automation-action-delay") +export class HaDelayAction extends LitElement implements ActionElement { + @property() public hass!: HomeAssistant; + @property() public action!: DelayAction; + + public static get defaultConfig() { + return { delay: "" }; + } + + public render() { + const { delay } = this.action; + + return html` + + `; + } + + private _valueChanged(ev: CustomEvent): void { + handleChangeEvent(this, ev); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-automation-action-delay": HaDelayAction; + } +} diff --git a/src/panels/config/automation/action/types/ha-automation-action-device_id.ts b/src/panels/config/automation/action/types/ha-automation-action-device_id.ts new file mode 100644 index 0000000000..003ddd55c0 --- /dev/null +++ b/src/panels/config/automation/action/types/ha-automation-action-device_id.ts @@ -0,0 +1,129 @@ +import "../../../../../components/device/ha-device-picker"; +import "../../../../../components/device/ha-device-action-picker"; +import "../../../../../components/ha-form/ha-form"; + +import { + fetchDeviceActionCapabilities, + deviceAutomationsEqual, + DeviceAction, +} from "../../../../../data/device_automation"; +import { LitElement, customElement, property, html } from "lit-element"; +import { fireEvent } from "../../../../../common/dom/fire_event"; +import { HomeAssistant } from "../../../../../types"; + +@customElement("ha-automation-action-device_id") +export class HaDeviceAction extends LitElement { + @property() public hass!: HomeAssistant; + @property() public action!: DeviceAction; + @property() private _deviceId?: string; + @property() private _capabilities?; + private _origAction?: DeviceAction; + + public static get defaultConfig() { + return { + device_id: "", + domain: "", + entity_id: "", + }; + } + + protected render() { + const deviceId = this._deviceId || this.action.device_id; + const extraFieldsData = + this._capabilities && this._capabilities.extra_fields + ? this._capabilities.extra_fields.map((item) => { + return { [item.name]: this.action[item.name] }; + }) + : undefined; + + return html` + + + ${extraFieldsData + ? html` + + ` + : ""} + `; + } + + protected firstUpdated() { + if (!this._capabilities) { + this._getCapabilities(); + } + if (this.action) { + this._origAction = this.action; + } + } + + protected updated(changedPros) { + const prevAction = changedPros.get("action"); + if (prevAction && !deviceAutomationsEqual(prevAction, this.action)) { + this._getCapabilities(); + } + } + + private async _getCapabilities() { + const action = this.action; + + this._capabilities = action.domain + ? await fetchDeviceActionCapabilities(this.hass, action) + : null; + } + + private _devicePicked(ev) { + ev.stopPropagation(); + this._deviceId = ev.target.value; + } + + private _deviceActionPicked(ev) { + ev.stopPropagation(); + let action = ev.detail.value; + if (this._origAction && deviceAutomationsEqual(this._origAction, action)) { + action = this._origAction; + } + fireEvent(this, "value-changed", { value: action }); + } + + private _extraFieldsChanged(ev) { + ev.stopPropagation(); + fireEvent(this, "value-changed", { + value: { + ...this.action, + ...ev.detail.value, + }, + }); + } + + private _extraFieldsComputeLabelCallback(localize) { + // Returns a callback for ha-form to calculate labels per schema object + return (schema) => + localize( + `ui.panel.config.automation.editor.actions.type.device.extra_fields.${schema.name}` + ) || schema.name; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-automation-action-device_id": HaDeviceAction; + } +} diff --git a/src/panels/config/automation/action/types/ha-automation-action-event.ts b/src/panels/config/automation/action/types/ha-automation-action-event.ts new file mode 100644 index 0000000000..b1c1759788 --- /dev/null +++ b/src/panels/config/automation/action/types/ha-automation-action-event.ts @@ -0,0 +1,53 @@ +import "@polymer/paper-input/paper-input"; +import "../../../../../components/ha-service-picker"; +import "../../../../../components/entity/ha-entity-picker"; +import "../../../../../components/ha-yaml-editor"; + +import { LitElement, property, customElement } from "lit-element"; +import { ActionElement, handleChangeEvent } from "../ha-automation-action-row"; +import { HomeAssistant } from "../../../../../types"; +import { html } from "lit-html"; +import { EventAction } from "../../../../../data/script"; + +@customElement("ha-automation-action-event") +export class HaEventAction extends LitElement implements ActionElement { + @property() public hass!: HomeAssistant; + @property() public action!: EventAction; + + public static get defaultConfig(): EventAction { + return { event: "", event_data: {} }; + } + + public render() { + const { event, event_data } = this.action; + + return html` + + + `; + } + + private _valueChanged(ev: CustomEvent): void { + handleChangeEvent(this, ev); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-automation-action-event": HaEventAction; + } +} diff --git a/src/panels/config/automation/action/types/ha-automation-action-scene.ts b/src/panels/config/automation/action/types/ha-automation-action-scene.ts new file mode 100644 index 0000000000..112ab89b81 --- /dev/null +++ b/src/panels/config/automation/action/types/ha-automation-action-scene.ts @@ -0,0 +1,45 @@ +import "../../../../../components/entity/ha-entity-picker"; + +import { LitElement, property, customElement, html } from "lit-element"; +import { ActionElement } from "../ha-automation-action-row"; +import { HomeAssistant } from "../../../../../types"; +import { PolymerChangedEvent } from "../../../../../polymer-types"; +import { fireEvent } from "../../../../../common/dom/fire_event"; +import { SceneAction } from "../../../../../data/script"; + +@customElement("ha-automation-action-scene") +export class HaSceneAction extends LitElement implements ActionElement { + @property() public hass!: HomeAssistant; + @property() public action!: SceneAction; + + public static get defaultConfig(): SceneAction { + return { scene: "" }; + } + + protected render() { + const { scene } = this.action; + + return html` + + `; + } + + private _entityPicked(ev: PolymerChangedEvent) { + ev.stopPropagation(); + fireEvent(this, "value-changed", { + value: { ...this.action, scene: ev.detail.value }, + }); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-automation-action-scene": HaSceneAction; + } +} diff --git a/src/panels/config/automation/action/types/ha-automation-action-service.ts b/src/panels/config/automation/action/types/ha-automation-action-service.ts new file mode 100644 index 0000000000..f00f854eae --- /dev/null +++ b/src/panels/config/automation/action/types/ha-automation-action-service.ts @@ -0,0 +1,107 @@ +import "@polymer/paper-input/paper-input"; +import "../../../../../components/ha-service-picker"; +import "../../../../../components/entity/ha-entity-picker"; +import "../../../../../components/ha-yaml-editor"; + +import { LitElement, property, customElement } from "lit-element"; +import { ActionElement, handleChangeEvent } from "../ha-automation-action-row"; +import { HomeAssistant } from "../../../../../types"; +import { html } from "lit-html"; +import memoizeOne from "memoize-one"; +import { computeDomain } from "../../../../../common/entity/compute_domain"; +import { computeObjectId } from "../../../../../common/entity/compute_object_id"; +import { PolymerChangedEvent } from "../../../../../polymer-types"; +import { fireEvent } from "../../../../../common/dom/fire_event"; +import { ServiceAction } from "../../../../../data/script"; + +@customElement("ha-automation-action-service") +export class HaServiceAction extends LitElement implements ActionElement { + @property() public hass!: HomeAssistant; + @property() public action!: ServiceAction; + + public static get defaultConfig() { + return { service: "", data: {} }; + } + + private _getServiceData = memoizeOne((service: string) => { + if (!service) { + return []; + } + const domain = computeDomain(service); + const serviceName = computeObjectId(service); + const serviceDomains = this.hass.services; + if (!(domain in serviceDomains)) { + return []; + } + if (!(serviceName in serviceDomains[domain])) { + return []; + } + + const fields = serviceDomains[domain][serviceName].fields; + return Object.keys(fields).map((field) => { + return { key: field, ...fields[field] }; + }); + }); + + public render() { + const { service, data, entity_id } = this.action; + + const serviceData = this._getServiceData(service); + const entity = serviceData.find((attr) => attr.key === "entity_id"); + + return html` + + ${entity + ? html` + + ` + : ""} + + `; + } + + private _valueChanged(ev: CustomEvent): void { + handleChangeEvent(this, ev); + } + + private _serviceChanged(ev: PolymerChangedEvent) { + ev.stopPropagation(); + if (ev.detail.value === this.action.service) { + return; + } + fireEvent(this, "value-changed", { + value: { ...this.action, service: ev.detail.value }, + }); + } + + private _entityPicked(ev: PolymerChangedEvent) { + ev.stopPropagation(); + fireEvent(this, "value-changed", { + value: { ...this.action, entity_id: ev.detail.value }, + }); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-automation-action-service": HaServiceAction; + } +} diff --git a/src/panels/config/automation/action/types/ha-automation-action-wait_template.ts b/src/panels/config/automation/action/types/ha-automation-action-wait_template.ts new file mode 100644 index 0000000000..5d54469acd --- /dev/null +++ b/src/panels/config/automation/action/types/ha-automation-action-wait_template.ts @@ -0,0 +1,51 @@ +import "@polymer/paper-input/paper-input"; + +import { LitElement, property, customElement } from "lit-element"; +import { ActionElement, handleChangeEvent } from "../ha-automation-action-row"; +import { HomeAssistant } from "../../../../../types"; +import { html } from "lit-html"; +import { WaitAction } from "../../../../../data/script"; + +@customElement("ha-automation-action-wait_template") +export class HaWaitAction extends LitElement implements ActionElement { + @property() public hass!: HomeAssistant; + @property() public action!: WaitAction; + + public static get defaultConfig() { + return { wait_template: "", timeout: "" }; + } + + protected render() { + const { wait_template, timeout } = this.action; + + return html` + + + `; + } + + private _valueChanged(ev: CustomEvent): void { + handleChangeEvent(this, ev); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-automation-action-wait_template": HaWaitAction; + } +} 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 5b84048539..57a9726bc3 100644 --- a/src/panels/config/automation/condition/ha-automation-condition-editor.ts +++ b/src/panels/config/automation/condition/ha-automation-condition-editor.ts @@ -38,9 +38,6 @@ export default class HaAutomationConditionEditor extends LitElement { @property() public yamlMode = false; protected render() { - if (!this.condition) { - return html``; - } const selected = OPTIONS.indexOf(this.condition.condition); const yamlMode = this.yamlMode || selected === -1; return html` diff --git a/src/panels/config/automation/condition/types/ha-automation-condition-device.ts b/src/panels/config/automation/condition/types/ha-automation-condition-device.ts index 02f36ea432..1d5bfd4ff1 100644 --- a/src/panels/config/automation/condition/types/ha-automation-condition-device.ts +++ b/src/panels/config/automation/condition/types/ha-automation-condition-device.ts @@ -28,9 +28,8 @@ export class HaDeviceCondition extends LitElement { } protected render() { - if (this._deviceId === undefined) { - this._deviceId = this.condition.device_id; - } + const deviceId = this._deviceId || this.condition.device_id; + const extraFieldsData = this._capabilities && this._capabilities.extra_fields ? this._capabilities.extra_fields.map((item) => { @@ -40,14 +39,14 @@ export class HaDeviceCondition extends LitElement { return html` { @@ -40,14 +39,14 @@ export class HaDeviceTrigger extends LitElement { return html` extends Component { - // @ts-ignore - protected initialized: boolean; - - constructor(props?, context?) { - super(props, context); - this.initialized = false; - } - - public componentDidMount() { - this.initialized = true; - } - - public componentWillUnmount() { - this.initialized = false; - } - - public render(_props?, _state?, _context?: any): ComponentChild { - return
; - } -} diff --git a/src/panels/config/js/automation.tsx b/src/panels/config/js/automation.tsx index 41b4ee7bb9..5d146db3ed 100644 --- a/src/panels/config/js/automation.tsx +++ b/src/panels/config/js/automation.tsx @@ -7,8 +7,7 @@ import "../../../components/ha-textarea"; import "../automation/trigger/ha-automation-trigger"; import "../automation/condition/ha-automation-condition"; - -import Script from "./script/index"; +import "../automation/action/ha-automation-action"; export default class Automation extends Component { constructor() { @@ -38,8 +37,8 @@ export default class Automation extends Component { }); } - public actionChanged(action) { - this.props.onChange({ ...this.props.automation, action }); + public actionChanged(ev: CustomEvent) { + this.props.onChange({ ...this.props.automation, action: ev.detail.value }); } public render({ automation, isWide, hass, localize }) { @@ -144,11 +143,10 @@ export default class Automation extends Component { {localize("ui.panel.config.automation.editor.actions.learn_more")} -