diff --git a/src/common/ensure-array.ts b/src/common/ensure-array.ts new file mode 100644 index 0000000000..43d3dbb1f5 --- /dev/null +++ b/src/common/ensure-array.ts @@ -0,0 +1,6 @@ +export const ensureArray = (value?: any) => { + if (!value || Array.isArray(value)) { + return value; + } + return [value]; +}; diff --git a/src/components/device/ha-area-devices-picker.ts b/src/components/device/ha-area-devices-picker.ts index bfd61e7479..ed3d5c25f3 100644 --- a/src/components/device/ha-area-devices-picker.ts +++ b/src/components/device/ha-area-devices-picker.ts @@ -139,7 +139,7 @@ export class HaAreaDevicesPicker extends SubscribeMixin(LitElement) { private _filteredDevices: DeviceRegistryEntry[] = []; - private _getDevices = memoizeOne( + private _getAreasWithDevices = memoizeOne( ( devices: DeviceRegistryEntry[], areas: AreaRegistryEntry[], @@ -277,7 +277,7 @@ export class HaAreaDevicesPicker extends SubscribeMixin(LitElement) { if (!this._devices || !this._areas || !this._entities) { return html``; } - const areas = this._getDevices( + const areas = this._getAreasWithDevices( this._devices, this._areas, this._entities, diff --git a/src/components/device/ha-device-picker.ts b/src/components/device/ha-device-picker.ts index bc43292268..fa7f1e70f1 100644 --- a/src/components/device/ha-device-picker.ts +++ b/src/components/device/ha-device-picker.ts @@ -111,6 +111,18 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) { @property({ type: Boolean }) private _opened?: boolean; + public open() { + this.updateComplete.then(() => { + (this.shadowRoot?.querySelector("vaadin-combo-box-light") as any)?.open(); + }); + } + + public focus() { + this.updateComplete.then(() => { + this.shadowRoot?.querySelector("paper-input")?.focus(); + }); + } + private _getDevices = memoizeOne( ( devices: DeviceRegistryEntry[], @@ -126,14 +138,17 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) { } const deviceEntityLookup: DeviceEntityLookup = {}; - for (const entity of entities) { - if (!entity.device_id) { - continue; + + if (includeDomains || excludeDomains || includeDeviceClasses) { + 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); } - if (!(entity.device_id in deviceEntityLookup)) { - deviceEntityLookup[entity.device_id] = []; - } - deviceEntityLookup[entity.device_id].push(entity); } const areaLookup: { [areaId: string]: AreaRegistryEntry } = {}; diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index 6b06f855c2..c83c6a44e3 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -101,6 +101,18 @@ export class HaEntityPicker extends LitElement { @query("vaadin-combo-box-light", true) private _comboBox!: HTMLElement; + public open() { + this.updateComplete.then(() => { + (this.shadowRoot?.querySelector("vaadin-combo-box-light") as any)?.open(); + }); + } + + public focus() { + this.updateComplete.then(() => { + this.shadowRoot?.querySelector("paper-input")?.focus(); + }); + } + private _initedStates = false; private _states: HassEntity[] = []; diff --git a/src/components/ha-area-picker.ts b/src/components/ha-area-picker.ts index d0ab2410c1..7a9cb710d1 100644 --- a/src/components/ha-area-picker.ts +++ b/src/components/ha-area-picker.ts @@ -29,6 +29,17 @@ import { SubscribeMixin } from "../mixins/subscribe-mixin"; import { PolymerChangedEvent } from "../polymer-types"; import { HomeAssistant } from "../types"; import memoizeOne from "memoize-one"; +import { + DeviceEntityLookup, + DeviceRegistryEntry, + subscribeDeviceRegistry, +} from "../data/device_registry"; +import { + EntityRegistryEntry, + subscribeEntityRegistry, +} from "../data/entity_registry"; +import { computeDomain } from "../common/entity/compute_domain"; +import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; const rowRenderer = ( root: HTMLElement, @@ -71,39 +82,225 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) { @property() public placeholder?: string; - @property() public _areas?: AreaRegistryEntry[]; - @property({ type: Boolean, attribute: "no-add" }) public noAdd?: boolean; + /** + * Show only areas with entities from specific domains. + * @type {Array} + * @attr include-domains + */ + @property({ type: Array, attribute: "include-domains" }) + public includeDomains?: string[]; + + /** + * Show no areas with entities of these domains. + * @type {Array} + * @attr exclude-domains + */ + @property({ type: Array, attribute: "exclude-domains" }) + public excludeDomains?: string[]; + + /** + * Show only areas with entities of these device classes. + * @type {Array} + * @attr include-device-classes + */ + @property({ type: Array, attribute: "include-device-classes" }) + public includeDeviceClasses?: string[]; + + @property() public deviceFilter?: HaDevicePickerDeviceFilterFunc; + + @property() public entityFilter?: (entity: EntityRegistryEntry) => boolean; + + @internalProperty() private _areas?: AreaRegistryEntry[]; + + @internalProperty() private _devices?: DeviceRegistryEntry[]; + + @internalProperty() private _entities?: EntityRegistryEntry[]; + @internalProperty() private _opened?: boolean; public hassSubscribe(): UnsubscribeFunc[] { return [ subscribeAreaRegistry(this.hass.connection!, (areas) => { - this._areas = this.noAdd - ? areas - : [ - ...areas, - { - area_id: "add_new", - name: this.hass.localize("ui.components.area-picker.add_new"), - }, - ]; + this._areas = areas; + }), + subscribeDeviceRegistry(this.hass.connection!, (devices) => { + this._devices = devices; + }), + subscribeEntityRegistry(this.hass.connection!, (entities) => { + this._entities = entities; }), ]; } + public open() { + this.updateComplete.then(() => { + (this.shadowRoot?.querySelector("vaadin-combo-box-light") as any)?.open(); + }); + } + + public focus() { + this.updateComplete.then(() => { + this.shadowRoot?.querySelector("paper-input")?.focus(); + }); + } + + private _getAreas = memoizeOne( + ( + areas: AreaRegistryEntry[], + devices: DeviceRegistryEntry[], + entities: EntityRegistryEntry[], + includeDomains: this["includeDomains"], + excludeDomains: this["excludeDomains"], + includeDeviceClasses: this["includeDeviceClasses"], + deviceFilter: this["deviceFilter"], + entityFilter: this["entityFilter"], + noAdd: this["noAdd"] + ): AreaRegistryEntry[] => { + const deviceEntityLookup: DeviceEntityLookup = {}; + let inputDevices: DeviceRegistryEntry[] | undefined; + let inputEntities: EntityRegistryEntry[] | undefined; + + if (includeDomains || excludeDomains || includeDeviceClasses) { + 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); + } + inputDevices = devices; + inputEntities = entities.filter((entity) => entity.area_id); + } else if (deviceFilter) { + inputDevices = devices; + } else if (entityFilter) { + inputEntities = entities.filter((entity) => entity.area_id); + } + + 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)) + ); + }); + inputEntities = inputEntities!.filter((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)) + ); + }); + inputEntities = inputEntities!.filter( + (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) + ); + }); + }); + inputEntities = inputEntities!.filter((entity) => { + const stateObj = this.hass.states[entity.entity_id]; + return ( + stateObj.attributes.device_class && + includeDeviceClasses.includes(stateObj.attributes.device_class) + ); + }); + } + + if (deviceFilter) { + inputDevices = inputDevices!.filter((device) => deviceFilter!(device)); + } + + if (entityFilter) { + entities = entities.filter((entity) => entityFilter!(entity)); + } + + let outputAreas = areas; + + let areaIds: string[] | undefined; + + if (inputDevices) { + areaIds = inputDevices + .filter((device) => device.area_id) + .map((device) => device.area_id!); + } + + if (inputEntities) { + areaIds = (areaIds ?? []).concat( + inputEntities + .filter((entity) => entity.area_id) + .map((entity) => entity.area_id!) + ); + } + + if (areaIds) { + outputAreas = areas.filter((area) => areaIds!.includes(area.area_id)); + } + + return noAdd + ? outputAreas + : [ + ...outputAreas, + { + area_id: "add_new", + name: this.hass.localize("ui.components.area-picker.add_new"), + }, + ]; + } + ); + protected render(): TemplateResult { - if (!this._areas) { + if (!this._devices || !this._areas || !this._entities) { return html``; } + const areas = this._getAreas( + this._areas, + this._devices, + this._entities, + this.includeDomains, + this.excludeDomains, + this.includeDeviceClasses, + this.deviceFilter, + this.entityFilter, + this.noAdd + ); return html` ` : ""} - ${this._areas.length > 0 + ${areas.length > 0 ? html` - ${this.buttons.map( - (button) => html` - - - - ` + ${this.buttons.map((button) => + button.iconPath + ? html` + + ` + : html`${button.label}` )} `; @@ -49,13 +55,15 @@ export class HaButtonToggleGroup extends LitElement { --mdc-icon-button-size: var(--button-toggle-size, 36px); --mdc-icon-size: var(--button-toggle-icon-size, 20px); } - mwc-icon-button { + mwc-icon-button, + mwc-button { border: 1px solid var(--primary-color); border-right-width: 0px; position: relative; cursor: pointer; } - mwc-icon-button::before { + mwc-icon-button::before, + mwc-button::before { top: 0; left: 0; width: 100%; @@ -67,17 +75,21 @@ export class HaButtonToggleGroup extends LitElement { content: ""; transition: opacity 15ms linear, background-color 15ms linear; } - mwc-icon-button[active]::before { + mwc-icon-button[active]::before, + mwc-button[active]::before { opacity: var(--mdc-icon-button-ripple-opacity, 0.12); } - mwc-icon-button:first-child { + mwc-icon-button:first-child, + mwc-button:first-child { border-radius: 4px 0 0 4px; } - mwc-icon-button:last-child { + mwc-icon-button:last-child, + mwc-button:last-child { border-radius: 0 4px 4px 0; border-right-width: 1px; } - mwc-icon-button:only-child { + mwc-icon-button:only-child, + mwc-button:only-child { border-radius: 4px; border-right-width: 1px; } diff --git a/src/components/ha-selector/ha-selector-action.ts b/src/components/ha-selector/ha-selector-action.ts new file mode 100644 index 0000000000..c6e06e3a12 --- /dev/null +++ b/src/components/ha-selector/ha-selector-action.ts @@ -0,0 +1,45 @@ +import { + css, + CSSResult, + customElement, + html, + LitElement, + property, +} from "lit-element"; +import { HomeAssistant } from "../../types"; +import { ActionSelector } from "../../data/selector"; +import { Action } from "../../data/script"; +import "../../panels/config/automation/action/ha-automation-action"; + +@customElement("ha-selector-action") +export class HaActionSelector extends LitElement { + @property() public hass!: HomeAssistant; + + @property() public selector!: ActionSelector; + + @property() public value?: Action; + + @property() public label?: string; + + protected render() { + return html``; + } + + static get styles(): CSSResult { + return css` + ha-automation-action { + display: block; + margin-bottom: 16px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-action": HaActionSelector; + } +} diff --git a/src/components/ha-selector/ha-selector-area.ts b/src/components/ha-selector/ha-selector-area.ts index fb3a051b69..d3f7d2f6a7 100644 --- a/src/components/ha-selector/ha-selector-area.ts +++ b/src/components/ha-selector/ha-selector-area.ts @@ -1,7 +1,16 @@ -import { customElement, html, LitElement, property } from "lit-element"; +import { + customElement, + html, + internalProperty, + LitElement, + property, +} from "lit-element"; import { HomeAssistant } from "../../types"; import { AreaSelector } from "../../data/selector"; import "../ha-area-picker"; +import { ConfigEntry, getConfigEntries } from "../../data/config_entries"; +import { DeviceRegistryEntry } from "../../data/device_registry"; +import { EntityRegistryEntry } from "../../data/entity_registry"; @customElement("ha-selector-area") export class HaAreaSelector extends LitElement { @@ -13,14 +22,76 @@ export class HaAreaSelector extends LitElement { @property() public label?: string; + @internalProperty() public _configEntries?: ConfigEntry[]; + + protected updated(changedProperties) { + if (changedProperties.has("selector")) { + const oldSelector = changedProperties.get("selector"); + if ( + oldSelector !== this.selector && + this.selector.area.device?.integration + ) { + this._loadConfigEntries(); + } + } + } + protected render() { return html` this._filterDevices(device)} + .entityFilter=${(entity) => this._filterEntities(entity)} + .includeDeviceClasses=${this.selector.area.entity?.device_class + ? [this.selector.area.entity.device_class] + : undefined} + .includeDomains=${this.selector.area.entity?.domain + ? [this.selector.area.entity.domain] + : undefined} >`; } + + private _filterEntities(entity: EntityRegistryEntry): boolean { + if (this.selector.area.entity?.integration) { + if (entity.platform !== this.selector.area.entity.integration) { + return false; + } + } + return true; + } + + private _filterDevices(device: DeviceRegistryEntry): boolean { + if ( + this.selector.area.device?.manufacturer && + device.manufacturer !== this.selector.area.device.manufacturer + ) { + return false; + } + if ( + this.selector.area.device?.model && + device.model !== this.selector.area.device.model + ) { + return false; + } + if (this.selector.area.device?.integration) { + if ( + !this._configEntries?.some((entry) => + device.config_entries.includes(entry.entry_id) + ) + ) { + return false; + } + } + return true; + } + + private async _loadConfigEntries() { + this._configEntries = (await getConfigEntries(this.hass)).filter( + (entry) => entry.domain === this.selector.area.device?.integration + ); + } } declare global { diff --git a/src/components/ha-selector/ha-selector-entity.ts b/src/components/ha-selector/ha-selector-entity.ts index c32f995ae4..e6bfba75b5 100644 --- a/src/components/ha-selector/ha-selector-entity.ts +++ b/src/components/ha-selector/ha-selector-entity.ts @@ -19,7 +19,7 @@ export class HaEntitySelector extends SubscribeMixin(LitElement) { @property() public selector!: EntitySelector; - @internalProperty() private _entities?: Record; + @internalProperty() private _entityPlaformLookup?: Record; @property() public value?: any; @@ -45,7 +45,7 @@ export class HaEntitySelector extends SubscribeMixin(LitElement) { } entityLookup[confEnt.entity_id] = confEnt.platform; } - this._entities = entityLookup; + this._entityPlaformLookup = entityLookup; }), ]; } @@ -66,8 +66,9 @@ export class HaEntitySelector extends SubscribeMixin(LitElement) { } if (this.selector.entity.integration) { if ( - !this._entities || - this._entities[entity.entity_id] !== this.selector.entity.integration + !this._entityPlaformLookup || + this._entityPlaformLookup[entity.entity_id] !== + this.selector.entity.integration ) { return false; } diff --git a/src/components/ha-selector/ha-selector-target.ts b/src/components/ha-selector/ha-selector-target.ts new file mode 100644 index 0000000000..aa6d2cfdb5 --- /dev/null +++ b/src/components/ha-selector/ha-selector-target.ts @@ -0,0 +1,153 @@ +import { + css, + CSSResult, + customElement, + html, + internalProperty, + LitElement, + property, +} from "lit-element"; +import { HomeAssistant } from "../../types"; +import { TargetSelector } from "../../data/selector"; +import { ConfigEntry, getConfigEntries } from "../../data/config_entries"; +import { DeviceRegistryEntry } from "../../data/device_registry"; +import "../ha-target-picker"; +import "@material/mwc-list/mwc-list-item"; +import "@polymer/paper-input/paper-input"; +import "@material/mwc-list/mwc-list"; +import { + EntityRegistryEntry, + subscribeEntityRegistry, +} from "../../data/entity_registry"; +import { Target } from "../../data/target"; +import "@material/mwc-tab-bar/mwc-tab-bar"; +import "@material/mwc-tab/mwc-tab"; +import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; +import { SubscribeMixin } from "../../mixins/subscribe-mixin"; + +@customElement("ha-selector-target") +export class HaTargetSelector extends SubscribeMixin(LitElement) { + @property() public hass!: HomeAssistant; + + @property() public selector!: TargetSelector; + + @property() public value?: Target; + + @property() public label?: string; + + @internalProperty() private _entityPlaformLookup?: Record; + + @internalProperty() private _configEntries?: ConfigEntry[]; + + public hassSubscribe(): UnsubscribeFunc[] { + return [ + subscribeEntityRegistry(this.hass.connection!, (entities) => { + const entityLookup = {}; + for (const confEnt of entities) { + if (!confEnt.platform) { + continue; + } + entityLookup[confEnt.entity_id] = confEnt.platform; + } + this._entityPlaformLookup = entityLookup; + }), + ]; + } + + protected updated(changedProperties) { + if (changedProperties.has("selector")) { + const oldSelector = changedProperties.get("selector"); + if ( + oldSelector !== this.selector && + this.selector.target.device?.integration + ) { + this._loadConfigEntries(); + } + } + } + + protected render() { + return html` this._filterDevices(device)} + .entityRegFilter=${(entity: EntityRegistryEntry) => + this._filterRegEntities(entity)} + .entityFilter=${(entity: HassEntity) => this._filterEntities(entity)} + .includeDeviceClasses=${this.selector.target.entity?.device_class + ? [this.selector.target.entity.device_class] + : undefined} + .includeDomains=${this.selector.target.entity?.domain + ? [this.selector.target.entity.domain] + : undefined} + >`; + } + + private _filterEntities(entity: HassEntity): boolean { + if (this.selector.target.entity?.integration) { + if ( + !this._entityPlaformLookup || + this._entityPlaformLookup[entity.entity_id] !== + this.selector.target.entity.integration + ) { + return false; + } + } + return true; + } + + private _filterRegEntities(entity: EntityRegistryEntry): boolean { + if (this.selector.target.entity?.integration) { + if (entity.platform !== this.selector.target.entity.integration) { + return false; + } + } + return true; + } + + private _filterDevices(device: DeviceRegistryEntry): boolean { + if ( + this.selector.target.device?.manufacturer && + device.manufacturer !== this.selector.target.device.manufacturer + ) { + return false; + } + if ( + this.selector.target.device?.model && + device.model !== this.selector.target.device.model + ) { + return false; + } + if (this.selector.target.device?.integration) { + if ( + !this._configEntries?.some((entry) => + device.config_entries.includes(entry.entry_id) + ) + ) { + return false; + } + } + return true; + } + + private async _loadConfigEntries() { + this._configEntries = (await getConfigEntries(this.hass)).filter( + (entry) => entry.domain === this.selector.target.device?.integration + ); + } + + static get styles(): CSSResult { + return css` + ha-target-picker { + margin: 0 -8px; + display: block; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-target": HaTargetSelector; + } +} diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts index 5e607d61fd..3c88b2da25 100644 --- a/src/components/ha-selector/ha-selector.ts +++ b/src/components/ha-selector/ha-selector.ts @@ -5,9 +5,11 @@ import { HomeAssistant } from "../../types"; import "./ha-selector-entity"; import "./ha-selector-device"; import "./ha-selector-area"; +import "./ha-selector-target"; import "./ha-selector-number"; import "./ha-selector-boolean"; import "./ha-selector-time"; +import "./ha-selector-action"; import { Selector } from "../../data/selector"; @customElement("ha-selector") diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts new file mode 100644 index 0000000000..487e557568 --- /dev/null +++ b/src/components/ha-target-picker.ts @@ -0,0 +1,595 @@ +import { + css, + CSSResult, + customElement, + html, + internalProperty, + LitElement, + property, + query, + unsafeCSS, +} from "lit-element"; +import { HomeAssistant } from "../types"; +// @ts-ignore +import chipStyles from "@material/chips/dist/mdc.chips.min.css"; +import { + mdiSofa, + mdiDevices, + mdiClose, + mdiPlus, + mdiUnfoldMoreVertical, +} from "@mdi/js"; +import "./ha-svg-icon"; +import "./ha-icon"; +import "@material/mwc-icon-button/mwc-icon-button"; +import { classMap } from "lit-html/directives/class-map"; +import "@material/mwc-button/mwc-button"; +import { UnsubscribeFunc } from "home-assistant-js-websocket"; +import { + AreaRegistryEntry, + subscribeAreaRegistry, +} from "../data/area_registry"; +import { + DeviceRegistryEntry, + subscribeDeviceRegistry, +} from "../data/device_registry"; +import { + EntityRegistryEntry, + subscribeEntityRegistry, +} from "../data/entity_registry"; +import { SubscribeMixin } from "../mixins/subscribe-mixin"; +import { computeStateName } from "../common/entity/compute_state_name"; +import { stateIcon } from "../common/entity/state_icon"; +import { fireEvent } from "../common/dom/fire_event"; +import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; +import { computeDomain } from "../common/entity/compute_domain"; +import { Target } from "../data/target"; +import { ensureArray } from "../common/ensure-array"; +import "./entity/ha-entity-picker"; +import "./device/ha-device-picker"; +import "./ha-area-picker"; +import type { HaEntityPickerEntityFilterFunc } from "./entity/ha-entity-picker"; +import "@polymer/paper-tooltip/paper-tooltip"; + +@customElement("ha-target-picker") +export class HaTargetPicker extends SubscribeMixin(LitElement) { + @property() public hass!: HomeAssistant; + + @property() public value?: Target; + + @property() public label?: string; + + /** + * Show only targets with entities from specific domains. + * @type {Array} + * @attr include-domains + */ + @property({ type: Array, attribute: "include-domains" }) + public includeDomains?: string[]; + + /** + * Show only targets with entities of these device classes. + * @type {Array} + * @attr include-device-classes + */ + @property({ type: Array, attribute: "include-device-classes" }) + public includeDeviceClasses?: string[]; + + @property() public deviceFilter?: HaDevicePickerDeviceFilterFunc; + + @property() public entityRegFilter?: (entity: EntityRegistryEntry) => boolean; + + @property() public entityFilter?: HaEntityPickerEntityFilterFunc; + + @internalProperty() private _areas?: { [areaId: string]: AreaRegistryEntry }; + + @internalProperty() private _devices?: { + [deviceId: string]: DeviceRegistryEntry; + }; + + @internalProperty() private _entities?: EntityRegistryEntry[]; + + @internalProperty() private _addMode?: "area_id" | "entity_id" | "device_id"; + + @query("#input") private _inputElement?; + + public hassSubscribe(): UnsubscribeFunc[] { + return [ + subscribeAreaRegistry(this.hass.connection!, (areas) => { + const areaLookup: { [areaId: string]: AreaRegistryEntry } = {}; + for (const area of areas) { + areaLookup[area.area_id] = area; + } + this._areas = areaLookup; + }), + subscribeDeviceRegistry(this.hass.connection!, (devices) => { + const deviceLookup: { [deviceId: string]: DeviceRegistryEntry } = {}; + for (const device of devices) { + deviceLookup[device.id] = device; + } + this._devices = deviceLookup; + }), + subscribeEntityRegistry(this.hass.connection!, (entities) => { + this._entities = entities; + }), + ]; + } + + protected render() { + if (!this._areas || !this._devices || !this._entities) { + return html``; + } + return html`
+ ${ensureArray(this.value?.area_id)?.map((area_id) => { + const area = this._areas![area_id]; + return this._renderChip( + "area_id", + area_id, + area?.name || area_id, + undefined, + mdiSofa + ); + })} + ${ensureArray(this.value?.device_id)?.map((device_id) => { + const device = this._devices![device_id]; + return this._renderChip( + "device_id", + device_id, + device?.name || device_id, + undefined, + mdiDevices + ); + })} + ${ensureArray(this.value?.entity_id)?.map((entity_id) => { + const entity = this.hass.states[entity_id]; + return this._renderChip( + "entity_id", + entity_id, + entity ? computeStateName(entity) : entity_id, + entity ? stateIcon(entity) : undefined + ); + })} +
+ ${this._renderPicker()} +
+
+
+ + + + ${this.hass.localize( + "ui.components.target-picker.add_area_id" + )} + + +
+
+
+ + + + ${this.hass.localize( + "ui.components.target-picker.add_device_id" + )} + + +
+
+
+ + + + ${this.hass.localize( + "ui.components.target-picker.add_entity_id" + )} + + +
+
`; + } + + private async _showPicker(ev) { + this._addMode = ev.currentTarget.type; + await this.updateComplete; + setTimeout(() => { + this._inputElement?.open(); + this._inputElement?.focus(); + }, 0); + } + + private _renderChip( + type: string, + id: string, + name: string, + icon?: string, + iconPath?: string + ) { + return html` +
+ ${iconPath + ? html`` + : ""} + ${icon + ? html`` + : ""} + + + ${name} + + + ${type === "entity_id" + ? "" + : html` + + + + ${this.hass.localize( + `ui.components.target-picker.expand_${type}` + )} + `} + + + + + ${this.hass.localize( + `ui.components.target-picker.remove_${type}` + )} + +
+ `; + } + + private _renderPicker() { + switch (this._addMode) { + case "area_id": + return html``; + case "device_id": + return html``; + case "entity_id": + return html``; + } + return html``; + } + + private _targetPicked(ev) { + ev.stopPropagation(); + if (!ev.detail.value) { + return; + } + const value = ev.detail.value; + const target = ev.currentTarget; + target.value = ""; + this._addMode = undefined; + fireEvent(this, "value-changed", { + value: this.value + ? { + ...this.value, + [target.type]: this.value[target.type] + ? [...ensureArray(this.value[target.type]), value] + : value, + } + : { [target.type]: value }, + }); + } + + private _handleExpand(ev) { + const target = ev.currentTarget as any; + const newDevices: string[] = []; + const newEntities: string[] = []; + if (target.type === "area_id") { + Object.values(this._devices!).forEach((device) => { + if ( + device.area_id === target.id && + !this.value!.device_id?.includes(device.id) && + this._deviceMeetsFilter(device) + ) { + newDevices.push(device.id); + } + }); + this._entities!.forEach((entity) => { + if ( + entity.area_id === target.id && + !this.value!.entity_id?.includes(entity.entity_id) && + this._entityRegMeetsFilter(entity) + ) { + newEntities.push(entity.entity_id); + } + }); + } else if (target.type === "device_id") { + this._entities!.forEach((entity) => { + if ( + entity.device_id === target.id && + !this.value!.entity_id?.includes(entity.entity_id) && + this._entityRegMeetsFilter(entity) + ) { + newEntities.push(entity.entity_id); + } + }); + } else { + return; + } + let value = this.value; + if (newEntities.length) { + value = this._addItems(value, "entity_id", newEntities); + } + if (newDevices.length) { + value = this._addItems(value, "device_id", newDevices); + } + value = this._removeItem(value, target.type, target.id); + fireEvent(this, "value-changed", { value }); + } + + private _handleRemove(ev) { + const target = ev.currentTarget as any; + fireEvent(this, "value-changed", { + value: this._removeItem(this.value, target.type, target.id), + }); + } + + private _addItems( + value: this["value"], + type: string, + ids: string[] + ): this["value"] { + return { + ...value, + [type]: value![type] ? ensureArray(value![type])!.concat(ids) : ids, + }; + } + + private _removeItem( + value: this["value"], + type: string, + id: string + ): this["value"] { + return { + ...value, + [type]: ensureArray(value![type])!.filter((val) => val !== id), + }; + } + + private _deviceMeetsFilter(device: DeviceRegistryEntry): boolean { + const devEntities = this._entities?.filter( + (entity) => entity.device_id === device.id + ); + if (this.includeDomains) { + if (!devEntities || !devEntities.length) { + return false; + } + if ( + !devEntities.some((entity) => + this.includeDomains!.includes(computeDomain(entity.entity_id)) + ) + ) { + return false; + } + } + + if (this.includeDeviceClasses) { + if (!devEntities || !devEntities.length) { + return false; + } + if ( + !devEntities.some((entity) => { + const stateObj = this.hass.states[entity.entity_id]; + if (!stateObj) { + return false; + } + return ( + stateObj.attributes.device_class && + this.includeDeviceClasses!.includes( + stateObj.attributes.device_class + ) + ); + }) + ) { + return false; + } + } + + if (this.deviceFilter) { + return this.deviceFilter(device); + } + return true; + } + + private _entityRegMeetsFilter(entity: EntityRegistryEntry): boolean { + if ( + this.includeDomains && + !this.includeDomains.includes(computeDomain(entity.entity_id)) + ) { + return false; + } + if (this.includeDeviceClasses) { + const stateObj = this.hass.states[entity.entity_id]; + if (!stateObj) { + return false; + } + if ( + !stateObj.attributes.device_class || + !this.includeDeviceClasses!.includes(stateObj.attributes.device_class) + ) { + return false; + } + } + if (this.entityRegFilter) { + return this.entityRegFilter(entity); + } + return true; + } + + static get styles(): CSSResult { + return css` + ${unsafeCSS(chipStyles)} + .mdc-chip { + color: var(--primary-text-color); + } + .items { + z-index: 2; + } + .mdc-chip.add { + color: rgba(0, 0, 0, 0.87); + } + .mdc-chip:not(.add) { + cursor: default; + } + .mdc-chip mwc-icon-button { + --mdc-icon-button-size: 24px; + display: flex; + align-items: center; + outline: none; + } + .mdc-chip mwc-icon-button ha-svg-icon { + border-radius: 50%; + background: var(--secondary-text-color); + } + .mdc-chip__icon.mdc-chip__icon--trailing { + width: 16px; + height: 16px; + --mdc-icon-size: 14px; + color: var(--card-background-color); + } + .mdc-chip__icon--leading { + display: flex; + align-items: center; + justify-content: center; + --mdc-icon-size: 20px; + border-radius: 50%; + padding: 6px; + margin-left: -14px !important; + } + .expand-btn { + margin-right: 0; + } + .mdc-chip.area_id:not(.add) { + border: 2px solid #fed6a4; + background: var(--card-background-color); + } + .mdc-chip.area_id:not(.add) .mdc-chip__icon--leading, + .mdc-chip.area_id.add { + background: #fed6a4; + } + .mdc-chip.device_id:not(.add) { + border: 2px solid #a8e1fb; + background: var(--card-background-color); + } + .mdc-chip.device_id:not(.add) .mdc-chip__icon--leading, + .mdc-chip.device_id.add { + background: #a8e1fb; + } + .mdc-chip.entity_id:not(.add) { + border: 2px solid #d2e7b9; + background: var(--card-background-color); + } + .mdc-chip.entity_id:not(.add) .mdc-chip__icon--leading, + .mdc-chip.entity_id.add { + background: #d2e7b9; + } + .mdc-chip:hover { + z-index: 5; + } + paper-tooltip { + min-width: 200px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-target-picker": HaTargetPicker; + } +} diff --git a/src/data/automation.ts b/src/data/automation.ts index 88ee3f847c..272416abb0 100644 --- a/src/data/automation.ts +++ b/src/data/automation.ts @@ -6,7 +6,7 @@ import { navigate } from "../common/navigate"; import { Context, HomeAssistant } from "../types"; import { BlueprintInput } from "./blueprint"; import { DeviceCondition, DeviceTrigger } from "./device_automation"; -import { Action } from "./script"; +import { Action, MODES } from "./script"; export interface AutomationEntity extends HassEntityBase { attributes: HassEntityAttributeBase & { @@ -26,7 +26,7 @@ export interface ManualAutomationConfig { trigger: Trigger[]; condition?: Condition[]; action: Action[]; - mode?: "single" | "restart" | "queued" | "parallel"; + mode?: typeof MODES[number]; max?: number; } diff --git a/src/data/script.ts b/src/data/script.ts index cb434d7dfc..a7cfa7506a 100644 --- a/src/data/script.ts +++ b/src/data/script.ts @@ -7,13 +7,13 @@ import { navigate } from "../common/navigate"; import { HomeAssistant } from "../types"; import { Condition, Trigger } from "./automation"; -export const MODES = ["single", "restart", "queued", "parallel"]; +export const MODES = ["single", "restart", "queued", "parallel"] as const; export const MODES_MAX = ["queued", "parallel"]; export interface ScriptEntity extends HassEntityBase { attributes: HassEntityAttributeBase & { last_triggered: string; - mode: "single" | "restart" | "queued" | "parallel"; + mode: typeof MODES[number]; current?: number; max?: number; }; @@ -23,7 +23,7 @@ export interface ScriptConfig { alias: string; sequence: Action[]; icon?: string; - mode?: "single" | "restart" | "queued" | "parallel"; + mode?: typeof MODES[number]; max?: number; } diff --git a/src/data/selector.ts b/src/data/selector.ts index ab56f9f016..82749ae90c 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -2,9 +2,11 @@ export type Selector = | EntitySelector | DeviceSelector | AreaSelector + | TargetSelector | NumberSelector | BooleanSelector - | TimeSelector; + | TimeSelector + | ActionSelector; export interface EntitySelector { entity: { @@ -19,13 +21,41 @@ export interface DeviceSelector { integration?: string; manufacturer?: string; model?: string; - entity?: EntitySelector["entity"]; + entity?: { + domain?: EntitySelector["entity"]["domain"]; + device_class?: EntitySelector["entity"]["device_class"]; + }; }; } export interface AreaSelector { - // eslint-disable-next-line @typescript-eslint/ban-types - area: {}; + area: { + entity?: { + integration?: EntitySelector["entity"]["integration"]; + domain?: EntitySelector["entity"]["domain"]; + device_class?: EntitySelector["entity"]["device_class"]; + }; + device?: { + integration?: DeviceSelector["device"]["integration"]; + manufacturer?: DeviceSelector["device"]["manufacturer"]; + model?: DeviceSelector["device"]["model"]; + }; + }; +} + +export interface TargetSelector { + target: { + entity?: { + integration?: EntitySelector["entity"]["integration"]; + domain?: EntitySelector["entity"]["domain"]; + device_class?: EntitySelector["entity"]["device_class"]; + }; + device?: { + integration?: DeviceSelector["device"]["integration"]; + manufacturer?: DeviceSelector["device"]["manufacturer"]; + model?: DeviceSelector["device"]["model"]; + }; + }; } export interface NumberSelector { @@ -47,3 +77,8 @@ export interface TimeSelector { // eslint-disable-next-line @typescript-eslint/ban-types time: {}; } + +export interface ActionSelector { + // eslint-disable-next-line @typescript-eslint/ban-types + action: {}; +} diff --git a/src/data/target.ts b/src/data/target.ts new file mode 100644 index 0000000000..afddff0688 --- /dev/null +++ b/src/data/target.ts @@ -0,0 +1,5 @@ +export interface Target { + entity_id?: string[]; + device_id?: string[]; + area_id?: string[]; +} diff --git a/src/panels/config/automation/blueprint-automation-editor.ts b/src/panels/config/automation/blueprint-automation-editor.ts index 9229069d09..88702161b4 100644 --- a/src/panels/config/automation/blueprint-automation-editor.ts +++ b/src/panels/config/automation/blueprint-automation-editor.ts @@ -63,7 +63,7 @@ export class HaBlueprintAutomationEditor extends LitElement { protected render() { const blueprint = this._blueprint; - return html` + return html` ${!this.narrow ? html` ${this.config.alias} ` : ""} @@ -119,7 +119,7 @@ export class HaBlueprintAutomationEditor extends LitElement { - + ${this.hass.localize( "ui.panel.config.automation.editor.blueprint.header" @@ -185,6 +185,7 @@ export class HaBlueprintAutomationEditor extends LitElement { >` : html`
diff --git a/src/translations/en.json b/src/translations/en.json index e1902f4fc3..ce51d4fd3f 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -334,6 +334,16 @@ "show_attributes": "Show attributes" } }, + "target-picker": { + "expand_area_id": "Expand this area in the seperate devices and entities that it contains. After expanding it will not update the devices and entities when the area changes.", + "expand_device_id": "Expand this device in seperate entities. After expanding it will not update the entities when the device changes.", + "remove_area_id": "Remove area", + "remove_device_id": "Remove device", + "remove_entity_id": "Remove entity", + "add_area_id": "Pick area", + "add_device_id": "Pick device", + "add_entity_id": "Pick entity" + }, "user-picker": { "no_user": "No user", "add_user": "Add user",