From 0dab5828fbb3d0af96694c78581d018bb584a4ef Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 14 Nov 2019 13:22:44 +0100 Subject: [PATCH] Add Thingtalk automation generation (#4216) * thingtalk * works * Add device_class support and get placeholders from api * Update --- src/common/util/patch.ts | 21 ++ src/components/device/ha-device-picker.ts | 93 ++++- src/components/entity/ha-entity-picker.ts | 31 +- src/data/cloud.ts | 10 + .../config/automation/ha-automation-editor.ts | 5 +- .../config/automation/ha-automation-picker.ts | 28 +- .../automation/show-dialog-thingtalk.ts | 20 ++ .../automation/thingtalk/dialog-thingtalk.ts | 259 ++++++++++++++ .../thingtalk/ha-thingtalk-placeholders.ts | 338 ++++++++++++++++++ src/panels/config/js/script/device.tsx | 2 +- src/translations/en.json | 6 +- 11 files changed, 793 insertions(+), 20 deletions(-) create mode 100644 src/common/util/patch.ts create mode 100644 src/panels/config/automation/show-dialog-thingtalk.ts create mode 100644 src/panels/config/automation/thingtalk/dialog-thingtalk.ts create mode 100644 src/panels/config/automation/thingtalk/ha-thingtalk-placeholders.ts diff --git a/src/common/util/patch.ts b/src/common/util/patch.ts new file mode 100644 index 0000000000..933ff0abdb --- /dev/null +++ b/src/common/util/patch.ts @@ -0,0 +1,21 @@ +export const applyPatch = (data, path, value) => { + if (path.length === 1) { + data[path[0]] = value; + } else { + if (!data[path[0]]) { + data[path[0]] = {}; + } + return applyPatch(data[path[0]], path.slice(1), value); + } +}; + +export const getPath = (data, path) => { + if (path.length === 1) { + return data[path[0]]; + } else { + if (data[path[0]] === undefined) { + return undefined; + } + return getPath(data[path[0]], path.slice(1)); + } +}; diff --git a/src/components/device/ha-device-picker.ts b/src/components/device/ha-device-picker.ts index 0d9aff55e3..10ebaecb07 100644 --- a/src/components/device/ha-device-picker.ts +++ b/src/components/device/ha-device-picker.ts @@ -34,6 +34,7 @@ import { EntityRegistryEntry, subscribeEntityRegistry, } from "../../data/entity_registry"; +import { computeDomain } from "../../common/entity/compute_domain"; interface Device { name: string; @@ -64,20 +65,45 @@ const rowRenderer = (root: HTMLElement, _owner, model: { item: Device }) => { }; @customElement("ha-device-picker") -class HaDevicePicker extends SubscribeMixin(LitElement) { +export class HaDevicePicker extends SubscribeMixin(LitElement) { @property() public hass!: HomeAssistant; @property() public label?: string; @property() public value?: string; @property() public devices?: DeviceRegistryEntry[]; @property() public areas?: AreaRegistryEntry[]; @property() public entities?: EntityRegistryEntry[]; - @property({ type: Boolean }) private _opened?: boolean; + /** + * Show only devices with entities from specific domains. + * @type {Array} + * @attr include-domains + */ + @property({ type: Array, attribute: "include-domains" }) + public includeDomains?: string[]; + /** + * Show no devices with entities of these domains. + * @type {Array} + * @attr exclude-domains + */ + @property({ type: Array, attribute: "exclude-domains" }) + public excludeDomains?: string[]; + /** + * Show only deviced with entities of these device classes. + * @type {Array} + * @attr include-device-classes + */ + @property({ type: Array, attribute: "include-device-classes" }) + public includeDeviceClasses?: string[]; + @property({ type: Boolean }) + private _opened?: boolean; private _getDevices = memoizeOne( ( devices: DeviceRegistryEntry[], areas: AreaRegistryEntry[], - entities: EntityRegistryEntry[] + entities: EntityRegistryEntry[], + includeDomains: this["includeDomains"], + excludeDomains: this["excludeDomains"], + includeDeviceClasses: this["includeDeviceClasses"] ): Device[] => { if (!devices.length) { return []; @@ -99,7 +125,53 @@ class HaDevicePicker extends SubscribeMixin(LitElement) { areaLookup[area.area_id] = area; } - const outputDevices = devices.map((device) => { + let inputDevices = [...devices]; + + if (includeDomains) { + inputDevices = inputDevices.filter((device) => { + const devEntities = deviceEntityLookup[device.id]; + if (!devEntities || !devEntities.length) { + return false; + } + return deviceEntityLookup[device.id].some((entity) => + includeDomains.includes(computeDomain(entity.entity_id)) + ); + }); + } + + if (excludeDomains) { + inputDevices = inputDevices.filter((device) => { + const devEntities = deviceEntityLookup[device.id]; + if (!devEntities || !devEntities.length) { + return true; + } + return entities.every( + (entity) => + !excludeDomains.includes(computeDomain(entity.entity_id)) + ); + }); + } + + if (includeDeviceClasses) { + inputDevices = inputDevices.filter((device) => { + const devEntities = deviceEntityLookup[device.id]; + if (!devEntities || !devEntities.length) { + return false; + } + return deviceEntityLookup[device.id].some((entity) => { + const stateObj = this.hass.states[entity.entity_id]; + if (!stateObj) { + return false; + } + return ( + stateObj.attributes.device_class && + includeDeviceClasses.includes(stateObj.attributes.device_class) + ); + }); + }); + } + + const outputDevices = inputDevices.map((device) => { return { id: device.id, name: computeDeviceName( @@ -135,7 +207,14 @@ class HaDevicePicker extends SubscribeMixin(LitElement) { if (!this.devices || !this.areas || !this.entities) { return; } - const devices = this._getDevices(this.devices, this.areas, this.entities); + const devices = this._getDevices( + this.devices, + this.areas, + this.entities, + this.includeDomains, + this.excludeDomains, + this.includeDeviceClasses + ); return html` boolean; @@ -62,7 +63,7 @@ class HaEntityPicker extends LitElement { @property() public value?: string; /** * Show entities from specific domains. - * @type {string} + * @type {Array} * @attr include-domains */ @property({ type: Array, attribute: "include-domains" }) @@ -74,6 +75,13 @@ class HaEntityPicker extends LitElement { */ @property({ type: Array, attribute: "exclude-domains" }) public excludeDomains?: string[]; + /** + * Show only entities of these device classes. + * @type {Array} + * @attr include-device-classes + */ + @property({ type: Array, attribute: "include-device-classes" }) + public includeDeviceClasses?: string[]; @property() public entityFilter?: HaEntityPickerEntityFilterFunc; @property({ type: Boolean }) private _opened?: boolean; @property() private _hass?: HomeAssistant; @@ -83,7 +91,8 @@ class HaEntityPicker extends LitElement { hass: this["hass"], includeDomains: this["includeDomains"], excludeDomains: this["excludeDomains"], - entityFilter: this["entityFilter"] + entityFilter: this["entityFilter"], + includeDeviceClasses: this["includeDeviceClasses"] ) => { let states: HassEntity[] = []; @@ -94,18 +103,28 @@ class HaEntityPicker extends LitElement { if (includeDomains) { entityIds = entityIds.filter((eid) => - includeDomains.includes(eid.substr(0, eid.indexOf("."))) + includeDomains.includes(computeDomain(eid)) ); } if (excludeDomains) { entityIds = entityIds.filter( - (eid) => !excludeDomains.includes(eid.substr(0, eid.indexOf("."))) + (eid) => !excludeDomains.includes(computeDomain(eid)) ); } states = entityIds.sort().map((key) => hass!.states[key]); + if (includeDeviceClasses) { + states = states.filter( + (stateObj) => + // We always want to include the entity of the current value + stateObj.entity_id === this.value || + (stateObj.attributes.device_class && + includeDeviceClasses.includes(stateObj.attributes.device_class)) + ); + } + if (entityFilter) { states = states.filter( (stateObj) => @@ -113,6 +132,7 @@ class HaEntityPicker extends LitElement { stateObj.entity_id === this.value || entityFilter!(stateObj) ); } + return states; } ); @@ -130,7 +150,8 @@ class HaEntityPicker extends LitElement { this._hass, this.includeDomains, this.excludeDomains, - this.entityFilter + this.entityFilter, + this.includeDeviceClasses ); return html` diff --git a/src/data/cloud.ts b/src/data/cloud.ts index 5d5baef8eb..4d1ae35dcb 100644 --- a/src/data/cloud.ts +++ b/src/data/cloud.ts @@ -1,5 +1,7 @@ import { HomeAssistant } from "../types"; import { EntityFilter } from "../common/entity/entity_filter"; +import { AutomationConfig } from "./automation"; +import { PlaceholderContainer } from "../panels/config/automation/thingtalk/dialog-thingtalk"; interface CloudStatusBase { logged_in: boolean; @@ -63,6 +65,11 @@ export interface CloudWebhook { managed?: boolean; } +export interface ThingTalkConversion { + config: Partial; + placeholders: PlaceholderContainer; +} + export const fetchCloudStatus = (hass: HomeAssistant) => hass.callWS({ type: "cloud/status" }); @@ -91,6 +98,9 @@ export const disconnectCloudRemote = (hass: HomeAssistant) => export const fetchCloudSubscriptionInfo = (hass: HomeAssistant) => hass.callWS({ type: "cloud/subscription" }); +export const convertThingTalk = (hass: HomeAssistant, query: string) => + hass.callWS({ type: "cloud/thingtalk/convert", query }); + export const updateCloudPref = ( hass: HomeAssistant, prefs: { diff --git a/src/panels/config/automation/ha-automation-editor.ts b/src/panels/config/automation/ha-automation-editor.ts index 74d245004a..f0f08f36e2 100644 --- a/src/panels/config/automation/ha-automation-editor.ts +++ b/src/panels/config/automation/ha-automation-editor.ts @@ -170,7 +170,8 @@ export class HaAutomationEditor extends LitElement { } if (changedProps.has("creatingNew") && this.creatingNew && this.hass) { - this._dirty = false; + const initData = getAutomationEditorInitData(); + this._dirty = initData ? true : false; this._config = { alias: this.hass.localize( "ui.panel.config.automation.editor.default_name" @@ -179,7 +180,7 @@ export class HaAutomationEditor extends LitElement { trigger: [{ platform: "state" }], condition: [], action: [{ service: "" }], - ...getAutomationEditorInitData(), + ...initData, }; } diff --git a/src/panels/config/automation/ha-automation-picker.ts b/src/panels/config/automation/ha-automation-picker.ts index a1c69d2072..b73d173ca8 100644 --- a/src/panels/config/automation/ha-automation-picker.ts +++ b/src/panels/config/automation/ha-automation-picker.ts @@ -23,9 +23,15 @@ import { computeStateName } from "../../../common/entity/compute_state_name"; import { computeRTL } from "../../../common/util/compute_rtl"; import { haStyle } from "../../../resources/styles"; import { HomeAssistant } from "../../../types"; -import { AutomationEntity } from "../../../data/automation"; +import { + AutomationEntity, + showAutomationEditor, + AutomationConfig, +} from "../../../data/automation"; import format_date_time from "../../../common/datetime/format_date_time"; import { fireEvent } from "../../../common/dom/fire_event"; +import { showThingtalkDialog } from "./show-dialog-thingtalk"; +import { isComponentLoaded } from "../../../common/config/is_component_loaded"; @customElement("ha-automation-picker") class HaAutomationPicker extends LitElement { @@ -141,8 +147,7 @@ class HaAutomationPicker extends LitElement { )} - - +
+ @click=${this._createNew} + > +
`; } @@ -162,6 +168,17 @@ class HaAutomationPicker extends LitElement { fireEvent(this, "hass-more-info", { entityId }); } + private _createNew() { + if (!isComponentLoaded(this.hass, "cloud")) { + showAutomationEditor(this); + return; + } + showThingtalkDialog(this, { + callback: (config: Partial | undefined) => + showAutomationEditor(this, config), + }); + } + static get styles(): CSSResultArray { return [ haStyle, @@ -198,6 +215,7 @@ class HaAutomationPicker extends LitElement { bottom: 16px; right: 16px; z-index: 1; + cursor: pointer; } ha-fab[is-wide] { diff --git a/src/panels/config/automation/show-dialog-thingtalk.ts b/src/panels/config/automation/show-dialog-thingtalk.ts new file mode 100644 index 0000000000..12a1679de7 --- /dev/null +++ b/src/panels/config/automation/show-dialog-thingtalk.ts @@ -0,0 +1,20 @@ +import { fireEvent } from "../../../common/dom/fire_event"; +import { AutomationConfig } from "../../../data/automation"; + +export interface ThingtalkDialogParams { + callback: (config: Partial | undefined) => void; +} + +export const loadThingtalkDialog = () => + import(/* webpackChunkName: "thingtalk-dialog" */ "./thingtalk/dialog-thingtalk"); + +export const showThingtalkDialog = ( + element: HTMLElement, + dialogParams: ThingtalkDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "ha-dialog-thinktalk", + dialogImport: loadThingtalkDialog, + dialogParams, + }); +}; diff --git a/src/panels/config/automation/thingtalk/dialog-thingtalk.ts b/src/panels/config/automation/thingtalk/dialog-thingtalk.ts new file mode 100644 index 0000000000..442997509f --- /dev/null +++ b/src/panels/config/automation/thingtalk/dialog-thingtalk.ts @@ -0,0 +1,259 @@ +import { + LitElement, + html, + css, + CSSResult, + TemplateResult, + property, + customElement, + query, +} from "lit-element"; +import "@polymer/paper-dialog-scrollable/paper-dialog-scrollable"; +import "@polymer/paper-input/paper-input"; +import "@polymer/paper-spinner/paper-spinner"; +import "@material/mwc-button"; + +import "../../../../components/dialog/ha-paper-dialog"; +import "./ha-thingtalk-placeholders"; +import { ThingtalkDialogParams } from "../show-dialog-thingtalk"; +import { PolymerChangedEvent } from "../../../../polymer-types"; +import { haStyleDialog, haStyle } from "../../../../resources/styles"; +import { HomeAssistant } from "../../../../types"; +// tslint:disable-next-line +import { PaperInputElement } from "@polymer/paper-input/paper-input"; +import { AutomationConfig } from "../../../../data/automation"; +// tslint:disable-next-line +import { PlaceholderValues } from "./ha-thingtalk-placeholders"; +import { convertThingTalk } from "../../../../data/cloud"; + +export interface Placeholder { + index: number; + fields: string[]; + domains: string[]; + device_classes?: string[]; +} + +export interface PlaceholderContainer { + [key: string]: Placeholder[]; +} + +@customElement("ha-dialog-thinktalk") +class DialogThingtalk extends LitElement { + @property() public hass!: HomeAssistant; + @property() private _error?: string; + @property() private _params?: ThingtalkDialogParams; + @property() private _submitting: boolean = false; + @property() private _opened = false; + @property() private _placeholders?: PlaceholderContainer; + + @query("#input") private _input?: PaperInputElement; + + private _value!: string; + private _config!: Partial; + + public showDialog(params: ThingtalkDialogParams): void { + this._params = params; + this._error = undefined; + this._opened = true; + } + + protected render(): TemplateResult | void { + if (!this._params) { + return html``; + } + if (this._placeholders) { + return html` + this._skip()} + @opened-changed=${this._openedChanged} + @placeholders-filled=${this._handlePlaceholders} + > + + `; + } + return html` + +

Create a new automation

+ + ${this._error + ? html` +
${this._error}
+ ` + : ""} + Type below what this automation should do, and we will try to convert + it into a Home Assistant automation. (only English is supported for + now)

+ For example: +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ + Powered by Almond +
+
+ + Skip + + + + Create automation + +
+
+ `; + } + + private async _generate() { + this._value = this._input!.value as string; + if (!this._value) { + this._error = "Enter a command or tap skip."; + return; + } + this._submitting = true; + let config: Partial; + let placeholders: PlaceholderContainer; + try { + const result = await convertThingTalk(this.hass, this._value); + config = result.config; + placeholders = result.placeholders; + } catch (err) { + this._error = err.message; + this._submitting = false; + return; + } + + this._submitting = false; + + if (!Object.keys(config).length) { + this._error = "We couldn't create an automation for that (yet?)."; + } else if (Object.keys(placeholders).length) { + this._config = config; + this._placeholders = placeholders; + } else { + this._sendConfig(this._value, config); + } + } + + private _handlePlaceholders(ev: CustomEvent) { + const placeholderValues = ev.detail.value as PlaceholderValues; + Object.entries(placeholderValues).forEach(([type, values]) => { + Object.entries(values).forEach(([index, placeholder]) => { + Object.entries(placeholder).forEach(([field, value]) => { + this._config[type][index][field] = value; + }); + }); + }); + this._sendConfig(this._value, this._config); + } + + private _sendConfig(input, config) { + this._params!.callback({ alias: input, ...config }); + this._closeDialog(); + } + + private _skip() { + this._params!.callback(undefined); + this._closeDialog(); + } + + private _closeDialog() { + this._placeholders = undefined; + if (this._input) { + this._input.value = null; + } + this._opened = false; + } + + private _openedChanged(ev: PolymerChangedEvent): void { + if (!ev.detail.value) { + this._closeDialog(); + } + } + + private _handleKeyUp(ev: KeyboardEvent) { + if (ev.keyCode === 13) { + this._generate(); + } + } + + private _handleExampleClick(ev: Event) { + this._input!.value = (ev.target as HTMLAnchorElement).innerText; + } + + static get styles(): CSSResult[] { + return [ + haStyle, + haStyleDialog, + css` + ha-paper-dialog { + max-width: 500px; + } + mwc-button.left { + margin-right: auto; + } + mwc-button paper-spinner { + width: 14px; + height: 14px; + margin-right: 20px; + } + paper-spinner { + display: none; + } + paper-spinner[active] { + display: block; + } + .error { + color: var(--google-red-500); + } + .attribution { + color: var(--secondary-text-color); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-dialog-thinktalk": DialogThingtalk; + } +} diff --git a/src/panels/config/automation/thingtalk/ha-thingtalk-placeholders.ts b/src/panels/config/automation/thingtalk/ha-thingtalk-placeholders.ts new file mode 100644 index 0000000000..f81e98a547 --- /dev/null +++ b/src/panels/config/automation/thingtalk/ha-thingtalk-placeholders.ts @@ -0,0 +1,338 @@ +import { + LitElement, + html, + TemplateResult, + property, + customElement, + css, + CSSResult, + query, +} from "lit-element"; +import { HomeAssistant } from "../../../../types"; +import { PolymerChangedEvent } from "../../../../polymer-types"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { haStyleDialog } from "../../../../resources/styles"; +import { PlaceholderContainer, Placeholder } from "./dialog-thingtalk"; +import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; +import { subscribeEntityRegistry } from "../../../../data/entity_registry"; +import { computeDomain } from "../../../../common/entity/compute_domain"; +import { HassEntity } from "home-assistant-js-websocket"; +import { HaDevicePicker } from "../../../../components/device/ha-device-picker"; +import { getPath, applyPatch } from "../../../../common/util/patch"; + +declare global { + // for fire event + interface HASSDomEvents { + "placeholders-filled": { value: PlaceholderValues }; + } +} + +export interface PlaceholderValues { + [key: string]: { [index: number]: { [key: string]: string } }; +} + +interface DeviceEntitiesLookup { + [deviceId: string]: string[]; +} + +@customElement("ha-thingtalk-placeholders") +export class ThingTalkPlaceholders extends SubscribeMixin(LitElement) { + @property() public hass!: HomeAssistant; + @property() public opened!: boolean; + public skip!: () => void; + @property() public placeholders!: PlaceholderContainer; + @property() private _error?: string; + private _deviceEntityLookup: DeviceEntitiesLookup = {}; + private _manualEntities: PlaceholderValues = {}; + @property() private _placeholderValues: PlaceholderValues = {}; + @query("#device-entity-picker") private _deviceEntityPicker?: HaDevicePicker; + + public hassSubscribe() { + return [ + subscribeEntityRegistry(this.hass.connection, (entries) => { + for (const entity of entries) { + if (!entity.device_id) { + continue; + } + if (!(entity.device_id in this._deviceEntityLookup)) { + this._deviceEntityLookup[entity.device_id] = []; + } + if ( + !this._deviceEntityLookup[entity.device_id].includes( + entity.entity_id + ) + ) { + this._deviceEntityLookup[entity.device_id].push(entity.entity_id); + } + } + }), + ]; + } + + protected render(): TemplateResult | void { + return html` + +

Great! Now we need to link some devices.

+ + ${this._error + ? html` +
${this._error}
+ ` + : ""} + ${Object.entries(this.placeholders).map( + ([type, placeholders]) => + html` +

+ ${this.hass.localize( + `ui.panel.config.automation.editor.${type}s.name` + )}: +

+ ${placeholders.map((placeholder) => { + if (placeholder.fields.includes("device_id")) { + return html` + + ${(getPath(this._placeholderValues, [ + type, + placeholder.index, + "device_id", + ]) && + placeholder.fields.includes("entity_id") && + getPath(this._placeholderValues, [ + type, + placeholder.index, + "entity_id", + ]) === undefined) || + getPath(this._manualEntities, [ + type, + placeholder.index, + "manual", + ]) === true + ? html` + + this._deviceEntityLookup[ + this._placeholderValues[type][ + placeholder.index + ].device_id + ].includes(state.entity_id)} + > + ` + : ""} + `; + } else if (placeholder.fields.includes("entity_id")) { + return html` + + `; + } + return html` +
+ Unknown placeholder
+ ${placeholder.domains}
+ ${placeholder.fields.map( + (field) => + html` + ${field}
+ ` + )} +
+ `; + })} + ` + )} +
+
+ + Skip + + + Create automation + +
+
+ `; + } + + private get _isDone(): boolean { + return Object.entries(this.placeholders).every(([type, placeholders]) => + placeholders.every((placeholder) => + placeholder.fields.every( + (field) => + getPath(this._placeholderValues, [ + type, + placeholder.index, + field, + ]) !== undefined + ) + ) + ); + } + + private _getLabel(domains: string[], deviceClasses?: string[]) { + return `${domains + .map((domain) => this.hass.localize(`domain.${domain}`)) + .join(", ")}${ + deviceClasses ? ` of type ${deviceClasses.join(", ")}` : "" + }`; + } + + private _devicePicked(ev: Event): void { + const target = ev.target as any; + const placeholder = target.placeholder as Placeholder; + const value = target.value; + const type = target.type; + applyPatch( + this._placeholderValues, + [type, placeholder.index, "device_id"], + value + ); + if (!placeholder.fields.includes("entity_id")) { + return; + } + if (value === "") { + delete this._placeholderValues[type][placeholder.index].entity_id; + if (this._deviceEntityPicker) { + this._deviceEntityPicker.value = undefined; + } + applyPatch( + this._manualEntities, + [type, placeholder.index, "manual"], + false + ); + this.requestUpdate("_placeholderValues"); + return; + } + const devEntities = this._deviceEntityLookup[value]; + const entities = devEntities.filter((eid) => { + if (placeholder.device_classes) { + const stateObj = this.hass.states[eid]; + if (!stateObj) { + return false; + } + return ( + placeholder.domains.includes(computeDomain(eid)) && + stateObj.attributes.device_class && + placeholder.device_classes.includes(stateObj.attributes.device_class) + ); + } + return placeholder.domains.includes(computeDomain(eid)); + }); + if (entities.length === 0) { + // Should not happen because we filter the device picker on domain + this._error = `No ${placeholder.domains + .map((domain) => this.hass.localize(`domain.${domain}`)) + .join(", ")} entities found in this device.`; + } else if (entities.length === 1) { + applyPatch( + this._placeholderValues, + [type, placeholder.index, "entity_id"], + entities[0] + ); + applyPatch( + this._manualEntities, + [type, placeholder.index, "manual"], + false + ); + this.requestUpdate("_placeholderValues"); + } else { + delete this._placeholderValues[type][placeholder.index].entity_id; + if (this._deviceEntityPicker) { + this._deviceEntityPicker.value = undefined; + } + applyPatch( + this._manualEntities, + [type, placeholder.index, "manual"], + true + ); + this.requestUpdate("_placeholderValues"); + } + } + + private _entityPicked(ev: Event): void { + const target = ev.target as any; + const placeholder = target.placeholder as Placeholder; + const value = target.value; + const type = target.type; + applyPatch( + this._placeholderValues, + [type, placeholder.index, "entity_id"], + value + ); + this.requestUpdate("_placeholderValues"); + } + + private _done(): void { + fireEvent(this, "placeholders-filled", { value: this._placeholderValues }); + } + + private _openedChanged(ev: PolymerChangedEvent): void { + // The opened-changed event doesn't leave the shadowdom so we re-dispatch it + this.dispatchEvent(new CustomEvent(ev.type, ev)); + } + + static get styles(): CSSResult[] { + return [ + haStyleDialog, + css` + ha-paper-dialog { + max-width: 500px; + } + mwc-button.left { + margin-right: auto; + } + paper-dialog-scrollable { + margin-top: 10px; + } + h3 { + margin: 10px 0 0 0; + font-weight: 500; + } + .error { + color: var(--google-red-500); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-thingtalk-placeholders": ThingTalkPlaceholders; + } +} diff --git a/src/panels/config/js/script/device.tsx b/src/panels/config/js/script/device.tsx index 3ae41ba5a8..b21aac338b 100644 --- a/src/panels/config/js/script/device.tsx +++ b/src/panels/config/js/script/device.tsx @@ -68,7 +68,7 @@ export default class DeviceActionEditor extends Component< {extraFieldsData && ( diff --git a/src/translations/en.json b/src/translations/en.json index a3175d6f1e..9e65a9aa79 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -531,7 +531,8 @@ }, "device-picker": { "clear": "Clear", - "show_devices": "Show devices" + "show_devices": "Show devices", + "device": "Device" }, "relative_time": { "past": "{time} ago", @@ -782,6 +783,7 @@ "placeholder": "Optional description" }, "triggers": { + "name": "Trigger", "header": "Triggers", "introduction": "Triggers are what starts the processing of an automation rule. It is possible to specify multiple triggers for the same rule. Once a trigger starts, Home Assistant will validate the conditions, if any, and call the action.", "learn_more": "Learn more about triggers", @@ -872,6 +874,7 @@ } }, "conditions": { + "name": "Condition", "header": "Conditions", "introduction": "Conditions are an optional part of an automation rule and can be used to prevent an action from happening when triggered. Conditions look very similar to triggers but are very different. A trigger will look at events happening in the system while a condition only looks at how the system looks right now. A trigger can observe that a switch is being turned on. A condition can only see if a switch is currently on or off.", "learn_more": "Learn more about conditions", @@ -932,6 +935,7 @@ } }, "actions": { + "name": "Action", "header": "Actions", "introduction": "The actions are what Home Assistant will do when the automation is triggered.", "learn_more": "Learn more about actions",