From f43abb5a9d91931234fd9f493e452453558efd97 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 2 Sep 2019 06:45:47 +0200 Subject: [PATCH] Support device triggers in automation editor (#3514) * Support device trigger in automation editor * Fix review comments, improve usability. * Lint * Lint * Improve styling, address review comments * Fix support for unknown stored automation * Fix * Lint * Lint * Index trigger by key, not by object * Fix no trigger case * Fix typing * Move trigger translations to backend * Rename WS command to device_automation/trigger/list * Tweak * Update src/data/device_automation.ts Co-Authored-By: Paulus Schoutsen * Address review comments * Fix.. * Simplify ha-device-trigger-picker * Fix changing device --- src/components/device/ha-device-picker.ts | 119 +++++++++++++ .../device/ha-device-trigger-picker.ts | 166 ++++++++++++++++++ src/data/device_automation.ts | 57 ++++++ src/panels/config/js/trigger/device.js | 52 ++++++ src/panels/config/js/trigger/trigger_edit.js | 2 + src/translations/en.json | 3 + 6 files changed, 399 insertions(+) create mode 100644 src/components/device/ha-device-picker.ts create mode 100644 src/components/device/ha-device-trigger-picker.ts create mode 100644 src/data/device_automation.ts create mode 100644 src/panels/config/js/trigger/device.js diff --git a/src/components/device/ha-device-picker.ts b/src/components/device/ha-device-picker.ts new file mode 100644 index 0000000000..f9c64578a1 --- /dev/null +++ b/src/components/device/ha-device-picker.ts @@ -0,0 +1,119 @@ +import "@polymer/paper-input/paper-input"; +import "@polymer/paper-item/paper-item"; +import "@polymer/paper-item/paper-item-body"; +import "@polymer/paper-dropdown-menu/paper-dropdown-menu-light"; +import "@polymer/paper-listbox/paper-listbox"; +import memoizeOne from "memoize-one"; +import { + LitElement, + TemplateResult, + html, + css, + CSSResult, + customElement, + property, +} from "lit-element"; +import { UnsubscribeFunc } from "home-assistant-js-websocket"; +import { HomeAssistant } from "../../types"; +import { fireEvent } from "../../common/dom/fire_event"; +import { + DeviceRegistryEntry, + subscribeDeviceRegistry, +} from "../../data/device_registry"; +import { compare } from "../../common/string/compare"; + +@customElement("ha-device-picker") +class HaDevicePicker extends LitElement { + public hass?: HomeAssistant; + @property() public label?: string; + @property() public value?: string; + @property() public devices?: DeviceRegistryEntry[]; + private _unsubDevices?: UnsubscribeFunc; + + private _sortedDevices = memoizeOne((devices?: DeviceRegistryEntry[]) => { + if (!devices || devices.length === 1) { + return devices || []; + } + const sorted = [...devices]; + sorted.sort((a, b) => compare(a.name || "", b.name || "")); + return sorted; + }); + + public connectedCallback() { + super.connectedCallback(); + this._unsubDevices = subscribeDeviceRegistry( + this.hass!.connection!, + (devices) => { + this.devices = devices; + } + ); + } + + public disconnectedCallback() { + super.disconnectedCallback(); + if (this._unsubDevices) { + this._unsubDevices(); + this._unsubDevices = undefined; + } + } + + protected render(): TemplateResult | void { + return html` + + + + No device + + ${this._sortedDevices(this.devices).map( + (device) => html` + + ${device.name_by_user || device.name} + + ` + )} + + + `; + } + + private get _value() { + return this.value || ""; + } + + private _deviceChanged(ev) { + const newValue = ev.detail.item.dataset.deviceId; + + if (newValue !== this._value) { + this.value = newValue; + setTimeout(() => { + fireEvent(this, "value-changed", { value: newValue }); + fireEvent(this, "change"); + }, 0); + } + } + + static get styles(): CSSResult { + return css` + paper-dropdown-menu-light { + width: 100%; + } + paper-listbox { + min-width: 200px; + } + paper-item { + cursor: pointer; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-device-picker": HaDevicePicker; + } +} diff --git a/src/components/device/ha-device-trigger-picker.ts b/src/components/device/ha-device-trigger-picker.ts new file mode 100644 index 0000000000..7ff3b6d48d --- /dev/null +++ b/src/components/device/ha-device-trigger-picker.ts @@ -0,0 +1,166 @@ +import "@polymer/paper-input/paper-input"; +import "@polymer/paper-item/paper-item"; +import "@polymer/paper-item/paper-item-body"; +import "@polymer/paper-listbox/paper-listbox"; +import { + LitElement, + TemplateResult, + html, + css, + CSSResult, + customElement, + property, +} from "lit-element"; +import { HomeAssistant } from "../../types"; +import { fireEvent } from "../../common/dom/fire_event"; +import { + DeviceTrigger, + fetchDeviceTriggers, + deviceAutomationTriggersEqual, + localizeDeviceAutomationTrigger, +} from "../../data/device_automation"; +import "../../components/ha-paper-dropdown-menu"; + +const NO_TRIGGER_KEY = "NO_TRIGGER"; +const UNKNOWN_TRIGGER_KEY = "UNKNOWN_TRIGGER"; + +@customElement("ha-device-trigger-picker") +class HaDeviceTriggerPicker extends LitElement { + public hass!: HomeAssistant; + @property() public label?: string; + @property() public deviceId?: string; + @property() public value?: DeviceTrigger; + @property() private _triggers: DeviceTrigger[] = []; + + // Trigger an empty render so we start with a clean DOM. + // paper-listbox does not like changing things around. + @property() private _renderEmpty = false; + + private get _key() { + if (!this.value) { + return NO_TRIGGER_KEY; + } + + const idx = this._triggers.findIndex((trigger) => + deviceAutomationTriggersEqual(trigger, this.value!) + ); + + if (idx === -1) { + return UNKNOWN_TRIGGER_KEY; + } + + return `${this._triggers[idx].device_id}_${idx}`; + } + + protected render(): TemplateResult | void { + if (this._renderEmpty) { + return html``; + } + return html` + + + + + ${this._triggers.map( + (trigger, idx) => html` + + ${localizeDeviceAutomationTrigger(this.hass, trigger)} + + ` + )} + + + `; + } + + protected updated(changedProps) { + super.updated(changedProps); + + if (changedProps.has("deviceId")) { + this._updateDeviceInfo(); + } + + // The value has changed, force the listbox to update + if (changedProps.has("value") || changedProps.has("_renderEmpty")) { + const listbox = this.shadowRoot!.querySelector("paper-listbox")!; + if (listbox) { + listbox._selectSelected(this._key); + } + } + } + + private async _updateDeviceInfo() { + this._triggers = this.deviceId + ? await fetchDeviceTriggers(this.hass!, this.deviceId!) + : // No device, clear the list of triggers + []; + + // If there is no value, or if we have changed the device ID, reset the value. + if (!this.value || this.value.device_id !== this.deviceId) { + this._setValue( + this._triggers.length ? this._triggers[0] : this._noTrigger + ); + } + this._renderEmpty = true; + await this.updateComplete; + this._renderEmpty = false; + } + + private get _noTrigger() { + return { + device_id: this.deviceId || "", + platform: "device", + domain: "", + entity_id: "", + }; + } + + private _triggerChanged(ev) { + this._setValue(ev.detail.item.trigger); + } + + private _setValue(trigger: DeviceTrigger) { + if (this.value && deviceAutomationTriggersEqual(trigger, this.value)) { + return; + } + this.value = trigger; + setTimeout(() => { + fireEvent(this, "change"); + }, 0); + } + + static get styles(): CSSResult { + return css` + ha-paper-dropdown-menu { + width: 100%; + } + paper-listbox { + min-width: 200px; + } + paper-item { + cursor: pointer; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-device-trigger-picker": HaDeviceTriggerPicker; + } +} diff --git a/src/data/device_automation.ts b/src/data/device_automation.ts new file mode 100644 index 0000000000..d3c7370527 --- /dev/null +++ b/src/data/device_automation.ts @@ -0,0 +1,57 @@ +import { HomeAssistant } from "../types"; +import compute_state_name from "../common/entity/compute_state_name"; + +export interface DeviceTrigger { + platform: string; + device_id: string; + domain: string; + entity_id: string; + type?: string; + event?: string; +} + +export interface DeviceTriggerList { + triggers: DeviceTrigger[]; +} + +export const fetchDeviceTriggers = (hass: HomeAssistant, deviceId: string) => + hass + .callWS({ + type: "device_automation/trigger/list", + device_id: deviceId, + }) + .then((response) => response.triggers); + +export const deviceAutomationTriggersEqual = ( + a: DeviceTrigger, + b: DeviceTrigger +) => { + if (typeof a !== typeof b) { + return false; + } + + for (const property in a) { + if (!Object.is(a[property], b[property])) { + return false; + } + } + for (const property in b) { + if (!Object.is(a[property], b[property])) { + return false; + } + } + + return true; +}; + +export const localizeDeviceAutomationTrigger = ( + hass: HomeAssistant, + trigger: DeviceTrigger +) => + hass.localize( + `component.${trigger.domain}.device_automation.trigger_type.${ + trigger.type + }`, + "name", + trigger.entity_id ? compute_state_name(hass!.states[trigger.entity_id]) : "" + ); diff --git a/src/panels/config/js/trigger/device.js b/src/panels/config/js/trigger/device.js new file mode 100644 index 0000000000..504eabdb7a --- /dev/null +++ b/src/panels/config/js/trigger/device.js @@ -0,0 +1,52 @@ +import { h, Component } from "preact"; + +import "../../../../components/device/ha-device-picker"; +import "../../../../components/device/ha-device-trigger-picker"; + +import { onChangeEvent } from "../../../../common/preact/event"; + +export default class DeviceTrigger extends Component { + constructor() { + super(); + this.onChange = onChangeEvent.bind(this, "trigger"); + this.devicePicked = this.devicePicked.bind(this); + this.deviceTriggerPicked = this.deviceTriggerPicked.bind(this); + this.state.device_id = undefined; + } + + devicePicked(ev) { + this.setState({ device_id: ev.target.value }); + } + + deviceTriggerPicked(ev) { + const deviceTrigger = ev.target.value; + this.props.onChange(this.props.index, (this.props.trigger = deviceTrigger)); + } + + /* eslint-disable camelcase */ + render({ trigger, hass }, { device_id }) { + if (device_id === undefined) device_id = trigger.device_id; + + return ( +
+ + +
+ ); + } +} + +DeviceTrigger.defaultConfig = { + device_id: "", +}; diff --git a/src/panels/config/js/trigger/trigger_edit.js b/src/panels/config/js/trigger/trigger_edit.js index 443c094a3a..e0234ed550 100644 --- a/src/panels/config/js/trigger/trigger_edit.js +++ b/src/panels/config/js/trigger/trigger_edit.js @@ -4,6 +4,7 @@ import "@polymer/paper-dropdown-menu/paper-dropdown-menu-light"; import "@polymer/paper-item/paper-item"; import "@polymer/paper-listbox/paper-listbox"; +import DeviceTrigger from "./device"; import EventTrigger from "./event"; import GeolocationTrigger from "./geo_location"; import HassTrigger from "./homeassistant"; @@ -18,6 +19,7 @@ import WebhookTrigger from "./webhook"; import ZoneTrigger from "./zone"; const TYPES = { + device: DeviceTrigger, event: EventTrigger, state: StateTrigger, geo_location: GeolocationTrigger, diff --git a/src/translations/en.json b/src/translations/en.json index 9047b48fc8..32999f17db 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -691,6 +691,9 @@ "unsupported_platform": "Unsupported platform: {platform}", "type_select": "Trigger type", "type": { + "device": { + "label": "Device" + }, "event": { "label": "Event", "event_type": "Event type",