diff --git a/src/components/device/ha-area-devices-picker.ts b/src/components/device/ha-area-devices-picker.ts new file mode 100644 index 000000000000..c3a2396fa646 --- /dev/null +++ b/src/components/device/ha-area-devices-picker.ts @@ -0,0 +1,412 @@ +import "@polymer/paper-input/paper-input"; +import "@polymer/paper-item/paper-item"; +import "@polymer/paper-item/paper-item-body"; +import "@vaadin/vaadin-combo-box/theme/material/vaadin-combo-box-light"; +import "@polymer/paper-listbox/paper-listbox"; +import memoizeOne from "memoize-one"; +import { + LitElement, + TemplateResult, + html, + css, + CSSResult, + customElement, + property, + PropertyValues, +} from "lit-element"; +import { UnsubscribeFunc } from "home-assistant-js-websocket"; +import { SubscribeMixin } from "../../mixins/subscribe-mixin"; +import "./ha-devices-picker"; + +import { HomeAssistant } from "../../types"; +import { fireEvent } from "../../common/dom/fire_event"; +import { + DeviceRegistryEntry, + subscribeDeviceRegistry, +} from "../../data/device_registry"; +import { compare } from "../../common/string/compare"; +import { PolymerChangedEvent } from "../../polymer-types"; +import { + AreaRegistryEntry, + subscribeAreaRegistry, +} from "../../data/area_registry"; +import { DeviceEntityLookup } from "../../panels/config/devices/ha-devices-data-table"; +import { + EntityRegistryEntry, + subscribeEntityRegistry, +} from "../../data/entity_registry"; +import { computeDomain } from "../../common/entity/compute_domain"; + +interface DevicesByArea { + [areaId: string]: AreaDevices; +} + +interface AreaDevices { + id?: string; + name: string; + devices: string[]; +} + +const rowRenderer = ( + root: HTMLElement, + _owner, + model: { item: AreaDevices } +) => { + if (!root.firstElementChild) { + root.innerHTML = ` + + + + [[item.name]] + [[item.devices.length]] devices + + + `; + } + root.querySelector(".name")!.textContent = model.item.name!; + root.querySelector( + "[secondary]" + )!.textContent = `${model.item.devices.length.toString()} devices`; +}; + +@customElement("ha-area-devices-picker") +export class HaAreaDevicesPicker extends SubscribeMixin(LitElement) { + @property() public hass!: HomeAssistant; + @property() public label?: string; + @property() public value?: string; + @property() public area?: string; + @property() public devices?: string[]; + /** + * 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; + @property() private _areaPicker = true; + @property() private _devices?: DeviceRegistryEntry[]; + @property() private _areas?: AreaRegistryEntry[]; + @property() private _entities?: EntityRegistryEntry[]; + private _selectedDevices: string[] = []; + private _filteredDevices: DeviceRegistryEntry[] = []; + + private _getDevices = memoizeOne( + ( + devices: DeviceRegistryEntry[], + areas: AreaRegistryEntry[], + entities: EntityRegistryEntry[], + includeDomains: this["includeDomains"], + excludeDomains: this["excludeDomains"], + includeDeviceClasses: this["includeDeviceClasses"] + ): AreaDevices[] => { + if (!devices.length) { + return []; + } + + const deviceEntityLookup: DeviceEntityLookup = {}; + for (const entity of entities) { + if (!entity.device_id) { + continue; + } + if (!(entity.device_id in deviceEntityLookup)) { + deviceEntityLookup[entity.device_id] = []; + } + deviceEntityLookup[entity.device_id].push(entity); + } + + 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) + ); + }); + }); + } + + this._filteredDevices = inputDevices; + + const areaLookup: { [areaId: string]: AreaRegistryEntry } = {}; + for (const area of areas) { + areaLookup[area.area_id] = area; + } + + const devicesByArea: DevicesByArea = {}; + + for (const device of inputDevices) { + const areaId = device.area_id; + if (areaId) { + if (!(areaId in devicesByArea)) { + devicesByArea[areaId] = { + id: areaId, + name: areaLookup[areaId].name, + devices: [], + }; + } + devicesByArea[areaId].devices.push(device.id); + } + } + + const sorted = Object.keys(devicesByArea) + .sort((a, b) => + compare(devicesByArea[a].name || "", devicesByArea[b].name || "") + ) + .map((key) => devicesByArea[key]); + + return sorted; + } + ); + + public hassSubscribe(): UnsubscribeFunc[] { + return [ + subscribeDeviceRegistry(this.hass.connection!, (devices) => { + this._devices = devices; + }), + subscribeAreaRegistry(this.hass.connection!, (areas) => { + this._areas = areas; + }), + subscribeEntityRegistry(this.hass.connection!, (entities) => { + this._entities = entities; + }), + ]; + } + + protected updated(changedProps: PropertyValues) { + if (changedProps.has("area") && this.area) { + this._areaPicker = true; + this.value = this.area; + } else if (changedProps.has("devices") && this.devices) { + this._areaPicker = false; + const filteredDeviceIds = this._filteredDevices.map( + (device) => device.id + ); + const selectedDevices = this.devices.filter((device) => + filteredDeviceIds.includes(device) + ); + this._setValue(selectedDevices); + } + } + + protected render(): TemplateResult | void { + if (!this._devices || !this._areas || !this._entities) { + return; + } + const areas = this._getDevices( + this._devices, + this._areas, + this._entities, + this.includeDomains, + this.excludeDomains, + this.includeDeviceClasses + ); + if (!this._areaPicker || areas.length === 0) { + return html` + + ${areas.length > 0 + ? html` + Choose an area + ` + : ""} + `; + } + return html` + + + ${this.value + ? html` + + Clear + + ` + : ""} + ${areas.length > 0 + ? html` + + Toggle + + ` + : ""} + + + Choose individual devices + `; + } + + private _clearValue(ev: Event) { + ev.stopPropagation(); + this._setValue([]); + } + + private get _value() { + return this.value || []; + } + + private _openedChanged(ev: PolymerChangedEvent) { + this._opened = ev.detail.value; + } + + private async _switchPicker() { + this._areaPicker = !this._areaPicker; + } + + private async _areaPicked(ev: PolymerChangedEvent) { + const value = ev.detail.value; + let selectedDevices = []; + const target = ev.target as any; + if (target.selectedItem) { + selectedDevices = target.selectedItem.devices; + } + + if (value !== this._value || this._selectedDevices !== selectedDevices) { + this._setValue(selectedDevices, value); + } + } + + private _devicesPicked(ev: CustomEvent) { + ev.stopPropagation(); + const selectedDevices = ev.detail.value; + this._setValue(selectedDevices); + } + + private _setValue(selectedDevices: string[], value = "") { + this.value = value; + this._selectedDevices = selectedDevices; + setTimeout(() => { + fireEvent(this, "value-changed", { value: selectedDevices }); + fireEvent(this, "change"); + }, 0); + } + + static get styles(): CSSResult { + return css` + paper-input > paper-icon-button { + width: 24px; + height: 24px; + padding: 2px; + color: var(--secondary-text-color); + } + [hidden] { + display: none; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-area-devices-picker": HaAreaDevicesPicker; + } +} diff --git a/src/components/device/ha-device-picker.ts b/src/components/device/ha-device-picker.ts index 10ebaecb0774..d7e0c15073a9 100644 --- a/src/components/device/ha-device-picker.ts +++ b/src/components/device/ha-device-picker.ts @@ -1,7 +1,7 @@ import "@polymer/paper-input/paper-input"; import "@polymer/paper-item/paper-item"; import "@polymer/paper-item/paper-item-body"; -import "@vaadin/vaadin-combo-box/vaadin-combo-box-light"; +import "@vaadin/vaadin-combo-box/theme/material/vaadin-combo-box-light"; import "@polymer/paper-listbox/paper-listbox"; import memoizeOne from "memoize-one"; import { diff --git a/src/components/device/ha-devices-picker.ts b/src/components/device/ha-devices-picker.ts new file mode 100644 index 000000000000..638b29e27d60 --- /dev/null +++ b/src/components/device/ha-devices-picker.ts @@ -0,0 +1,127 @@ +import { + LitElement, + TemplateResult, + property, + html, + customElement, +} from "lit-element"; +import "@polymer/paper-icon-button/paper-icon-button-light"; + +import { HomeAssistant } from "../../types"; +import { PolymerChangedEvent } from "../../polymer-types"; +import { fireEvent } from "../../common/dom/fire_event"; + +import "./ha-device-picker"; + +@customElement("ha-devices-picker") +class HaDevicesPicker extends LitElement { + @property() public hass?: HomeAssistant; + @property() public value?: string[]; + /** + * Show entities from specific domains. + * @type {string} + * @attr include-domains + */ + @property({ type: Array, attribute: "include-domains" }) + public includeDomains?: string[]; + /** + * Show no entities of these domains. + * @type {Array} + * @attr exclude-domains + */ + @property({ type: Array, attribute: "exclude-domains" }) + public excludeDomains?: string[]; + @property({ attribute: "picked-device-label" }) + @property({ type: Array, attribute: "include-device-classes" }) + public includeDeviceClasses?: string[]; + public pickedDeviceLabel?: string; + @property({ attribute: "pick-device-label" }) public pickDeviceLabel?: string; + + protected render(): TemplateResult | void { + if (!this.hass) { + return; + } + + const currentDevices = this._currentDevices; + return html` + ${currentDevices.map( + (entityId) => html` + + + + ` + )} + + + + `; + } + + private get _currentDevices() { + return this.value || []; + } + + private async _updateDevices(devices) { + fireEvent(this, "value-changed", { + value: devices, + }); + + this.value = devices; + } + + private _deviceChanged(event: PolymerChangedEvent) { + event.stopPropagation(); + const curValue = (event.currentTarget as any).curValue; + const newValue = event.detail.value; + if (newValue === curValue || newValue !== "") { + return; + } + if (newValue === "") { + this._updateDevices( + this._currentDevices.filter((dev) => dev !== curValue) + ); + } else { + this._updateDevices( + this._currentDevices.map((dev) => (dev === curValue ? newValue : dev)) + ); + } + } + + private async _addDevice(event: PolymerChangedEvent) { + event.stopPropagation(); + const toAdd = event.detail.value; + (event.currentTarget as any).value = ""; + if (!toAdd) { + return; + } + const currentDevices = this._currentDevices; + if (currentDevices.includes(toAdd)) { + return; + } + + this._updateDevices([...currentDevices, toAdd]); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-devices-picker": HaDevicesPicker; + } +} diff --git a/src/panels/config/automation/thingtalk/dialog-thingtalk.ts b/src/panels/config/automation/thingtalk/dialog-thingtalk.ts index 442997509fd4..09fa45a7f144 100644 --- a/src/panels/config/automation/thingtalk/dialog-thingtalk.ts +++ b/src/panels/config/automation/thingtalk/dialog-thingtalk.ts @@ -27,6 +27,7 @@ import { PlaceholderValues } from "./ha-thingtalk-placeholders"; import { convertThingTalk } from "../../../../data/cloud"; export interface Placeholder { + name: string; index: number; fields: string[]; domains: string[]; @@ -177,8 +178,21 @@ class DialogThingtalk extends LitElement { 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; + const devices = Object.values(placeholder); + if (devices.length === 1) { + Object.entries(devices[0]).forEach(([field, value]) => { + this._config[type][index][field] = value; + return; + }); + } + const automation = { ...this._config[type][index] }; + delete this._config[type][index]; + devices.forEach((fields) => { + const newAutomation = { ...automation }; + Object.entries(fields).forEach(([field, value]) => { + newAutomation[field] = value; + }); + this._config[type].push(newAutomation); }); }); }); diff --git a/src/panels/config/automation/thingtalk/ha-thingtalk-placeholders.ts b/src/panels/config/automation/thingtalk/ha-thingtalk-placeholders.ts index f81e98a54772..4afc2d7840ca 100644 --- a/src/panels/config/automation/thingtalk/ha-thingtalk-placeholders.ts +++ b/src/panels/config/automation/thingtalk/ha-thingtalk-placeholders.ts @@ -6,8 +6,11 @@ import { customElement, css, CSSResult, - query, + PropertyValues, } from "lit-element"; + +import "../../../../components/device/ha-area-devices-picker"; + import { HomeAssistant } from "../../../../types"; import { PolymerChangedEvent } from "../../../../polymer-types"; import { fireEvent } from "../../../../common/dom/fire_event"; @@ -17,8 +20,15 @@ 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"; +import { + subscribeAreaRegistry, + AreaRegistryEntry, +} from "../../../../data/area_registry"; +import { + subscribeDeviceRegistry, + DeviceRegistryEntry, +} from "../../../../data/device_registry"; declare global { // for fire event @@ -28,7 +38,23 @@ declare global { } export interface PlaceholderValues { - [key: string]: { [index: number]: { [key: string]: string } }; + [key: string]: { + [index: number]: { + [index: number]: { device_id?: string; entity_id?: string }; + }; + }; +} + +export interface ExtraInfo { + [key: string]: { + [index: number]: { + [index: number]: { + area_id?: string; + device_ids?: string[]; + manualEntity: boolean; + }; + }; + }; } interface DeviceEntitiesLookup { @@ -43,9 +69,11 @@ export class ThingTalkPlaceholders extends SubscribeMixin(LitElement) { @property() public placeholders!: PlaceholderContainer; @property() private _error?: string; private _deviceEntityLookup: DeviceEntitiesLookup = {}; - private _manualEntities: PlaceholderValues = {}; + @property() private _extraInfo: ExtraInfo = {}; @property() private _placeholderValues: PlaceholderValues = {}; - @query("#device-entity-picker") private _deviceEntityPicker?: HaDevicePicker; + private _devices?: DeviceRegistryEntry[]; + private _areas?: AreaRegistryEntry[]; + private _search = false; public hassSubscribe() { return [ @@ -66,12 +94,28 @@ export class ThingTalkPlaceholders extends SubscribeMixin(LitElement) { } } }), + subscribeDeviceRegistry(this.hass.connection!, (devices) => { + this._devices = devices; + this._searchNames(); + }), + subscribeAreaRegistry(this.hass.connection!, (areas) => { + this._areas = areas; + this._searchNames(); + }), ]; } + protected updated(changedProps: PropertyValues) { + if (changedProps.has("placeholders")) { + this._search = true; + this._searchNames(); + } + } + protected render(): TemplateResult | void { return html` ${placeholders.map((placeholder) => { if (placeholder.fields.includes("device_id")) { + const extraInfo = getPath(this._extraInfo, [ + type, + placeholder.index, + ]); 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 + > + ${extraInfo && extraInfo.manualEntity ? html` - - this._deviceEntityLookup[ - this._placeholderValues[type][ - placeholder.index - ].device_id - ].includes(state.entity_id)} - > + + One or more devices have more than one matching + entity, please pick the one you want to use. + + ${Object.keys(extraInfo.manualEntity).map( + (idx) => html` + { + const devId = this._placeholderValues[type][ + placeholder.index + ][idx].device_id; + return this._deviceEntityLookup[ + devId + ].includes(state.entity_id); + }} + > + ` + )} ` : ""} `; @@ -189,17 +244,74 @@ export class ThingTalkPlaceholders extends SubscribeMixin(LitElement) { `; } + private _getDeviceName(deviceId: string): string { + if (!this._devices) { + return ""; + } + const foundDevice = this._devices.find((device) => device.id === deviceId); + if (!foundDevice) { + return ""; + } + return foundDevice.name_by_user || foundDevice.name || ""; + } + + private _searchNames() { + if (!this._search || !this._areas || !this._devices) { + return; + } + this._search = false; + Object.entries(this.placeholders).forEach(([type, placeholders]) => + placeholders.forEach((placeholder) => { + if (!placeholder.name) { + return; + } + const name = placeholder.name; + const foundArea = this._areas!.find((area) => + area.name.toLowerCase().includes(name) + ); + if (foundArea) { + applyPatch( + this._extraInfo, + [type, placeholder.index, "area_id"], + foundArea.area_id + ); + this.requestUpdate("_extraInfo"); + return; + } + const foundDevices = this._devices!.filter((device) => { + const deviceName = device.name_by_user || device.name; + if (!deviceName) { + return false; + } + return deviceName.toLowerCase().includes(name); + }); + if (foundDevices.length) { + applyPatch( + this._extraInfo, + [type, placeholder.index, "device_ids"], + foundDevices.map((device) => device.id) + ); + this.requestUpdate("_extraInfo"); + } + }) + ); + } + 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 - ) + placeholder.fields.every((field) => { + const entries: { + [key: number]: { device_id?: string; entity_id?: string }; + } = getPath(this._placeholderValues, [type, placeholder.index]); + if (!entries) { + return false; + } + const values = Object.values(entries); + return values.every( + (entry) => entry[field] !== undefined && entry[field] !== "" + ); + }) ) ); } @@ -212,76 +324,115 @@ export class ThingTalkPlaceholders extends SubscribeMixin(LitElement) { }`; } - private _devicePicked(ev: Event): void { + private _devicePicked(ev: CustomEvent): void { + const value: string[] = ev.detail.value; + if (!value) { + return; + } 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; + + let oldValues = getPath(this._placeholderValues, [type, placeholder.index]); + if (oldValues) { + oldValues = Object.values(oldValues); } - 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 - ); + const oldExtraInfo = getPath(this._extraInfo, [type, placeholder.index]); + + if (this._placeholderValues[type]) { + delete this._placeholderValues[type][placeholder.index]; + } + + if (this._extraInfo[type]) { + delete this._extraInfo[type][placeholder.index]; + } + + if (!value.length) { 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; + + value.forEach((deviceId, index) => { + let oldIndex; + if (oldValues) { + const oldDevice = oldValues.find((oldVal, idx) => { + oldIndex = idx; + return oldVal.device_id === deviceId; + }); + + if (oldDevice) { + applyPatch( + this._placeholderValues, + [type, placeholder.index, index], + oldDevice + ); + if (oldExtraInfo) { + applyPatch( + this._extraInfo, + [type, placeholder.index, index], + oldExtraInfo[oldIndex] + ); + } + return; } - 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 + [type, placeholder.index, index, "device_id"], + deviceId ); - this.requestUpdate("_placeholderValues"); - } else { - delete this._placeholderValues[type][placeholder.index].entity_id; - if (this._deviceEntityPicker) { - this._deviceEntityPicker.value = undefined; + + if (!placeholder.fields.includes("entity_id")) { + return; } - applyPatch( - this._manualEntities, - [type, placeholder.index, "manual"], - true - ); - this.requestUpdate("_placeholderValues"); - } + + const devEntities = this._deviceEntityLookup[deviceId]; + + 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, index, "entity_id"], + entities[0] + ); + this.requestUpdate("_placeholderValues"); + } else { + delete this._placeholderValues[type][placeholder.index][index] + .entity_id; + applyPatch( + this._extraInfo, + [type, placeholder.index, "manualEntity", index], + true + ); + this.requestUpdate("_placeholderValues"); + } + }); + + fireEvent( + this.shadowRoot!.querySelector("ha-paper-dialog")! as HTMLElement, + "iron-resize" + ); } private _entityPicked(ev: Event): void { @@ -289,9 +440,10 @@ export class ThingTalkPlaceholders extends SubscribeMixin(LitElement) { const placeholder = target.placeholder as Placeholder; const value = target.value; const type = target.type; + const index = target.index || 0; applyPatch( this._placeholderValues, - [type, placeholder.index, "entity_id"], + [type, placeholder.index, index, "entity_id"], value ); this.requestUpdate("_placeholderValues");