From 4c2ca9224d09afafc50ce76862aa5949bcbf1442 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 1 Dec 2020 11:13:08 +0100 Subject: [PATCH] Complete filtering of selectors --- .../device/ha-area-devices-picker.ts | 4 +- src/components/device/ha-device-picker.ts | 17 +- src/components/ha-area-picker.ts | 213 ++++++++++++++++-- src/components/ha-button-toggle-group.ts | 46 ++-- .../ha-selector/ha-selector-area.ts | 73 +++++- src/data/selector.ts | 19 +- 6 files changed, 328 insertions(+), 44 deletions(-) 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..74f0bd88d6 100644 --- a/src/components/device/ha-device-picker.ts +++ b/src/components/device/ha-device-picker.ts @@ -126,14 +126,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/ha-area-picker.ts b/src/components/ha-area-picker.ts index d0ab2410c1..b9b833427f 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,213 @@ 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; }), ]; } + 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.device_id); + } else if (deviceFilter) { + inputDevices = [...devices]; + } else if (entityFilter) { + inputEntities = entities.filter((entity) => !entity.device_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-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/data/selector.ts b/src/data/selector.ts index ab56f9f016..1ec552735e 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -19,13 +19,26 @@ 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 NumberSelector {