diff --git a/src/components/device/ha-device-picker.ts b/src/components/device/ha-device-picker.ts index c92a1ccb2d..5fc1bfff80 100644 --- a/src/components/device/ha-device-picker.ts +++ b/src/components/device/ha-device-picker.ts @@ -15,6 +15,7 @@ import { computeDeviceName, DeviceEntityLookup, DeviceRegistryEntry, + getDeviceEntityLookup, subscribeDeviceRegistry, } from "../../data/device_registry"; import { @@ -129,7 +130,7 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) { ]; } - const deviceEntityLookup: DeviceEntityLookup = {}; + let deviceEntityLookup: DeviceEntityLookup = {}; if ( includeDomains || @@ -137,15 +138,7 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) { includeDeviceClasses || entityFilter ) { - 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); - } + deviceEntityLookup = getDeviceEntityLookup(entities); } const areaLookup: { [areaId: string]: AreaRegistryEntry } = {}; diff --git a/src/components/ha-service-control.ts b/src/components/ha-service-control.ts index 12811bf4ed..bc0cc718c1 100644 --- a/src/components/ha-service-control.ts +++ b/src/components/ha-service-control.ts @@ -7,14 +7,20 @@ import { import { css, CSSResultGroup, html, LitElement, PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import memoizeOne from "memoize-one"; +import { ensureArray } from "../common/array/ensure-array"; import { fireEvent } from "../common/dom/fire_event"; import { computeDomain } from "../common/entity/compute_domain"; import { computeObjectId } from "../common/entity/compute_object_id"; +import { supportsFeature } from "../common/entity/supports-feature"; import { fetchIntegrationManifest, IntegrationManifest, } from "../data/integration"; -import { Selector } from "../data/selector"; +import { + expandAreaTarget, + expandDeviceTarget, + Selector, +} from "../data/selector"; import { ValueChangedEvent, HomeAssistant } from "../types"; import { documentationUrl } from "../util/documentation-url"; import "./ha-checkbox"; @@ -25,6 +31,16 @@ import "./ha-settings-row"; import "./ha-yaml-editor"; import type { HaYamlEditor } from "./ha-yaml-editor"; +const attributeFilter = (values: any[], attribute: any) => { + if (typeof attribute === "object") { + if (Array.isArray(attribute)) { + return attribute.some((item) => values.includes(item)); + } + return false; + } + return values.includes(attribute); +}; + const showOptionalToggle = (field) => field.selector && !field.required && @@ -39,6 +55,10 @@ interface ExtHassService extends Omit { advanced?: boolean; default?: any; example?: any; + filter?: { + supported_features?: number[]; + attribute?: Record; + }; selector?: Selector; }[]; hasSelector: string[]; @@ -213,6 +233,87 @@ export class HaServiceControl extends LitElement { } ); + private _filterFields = memoizeOne( + (serviceData: ExtHassService | undefined, value: this["value"]) => + serviceData?.fields?.filter( + (field) => + !field.filter || + this._filterField(serviceData.target, field.filter, value) + ) + ); + + private _filterField( + target: ExtHassService["target"], + filter: ExtHassService["fields"][number]["filter"], + value: this["value"] + ) { + const targetSelector = target ? { target } : { target: {} }; + const targetEntities = + ensureArray(value?.target?.entity_id || value?.data?.entity_id) || []; + const targetDevices = + ensureArray(value?.target?.device_id || value?.data?.device_id) || []; + const targetAreas = ensureArray( + value?.target?.area_id || value?.data?.area_id + ); + if (targetAreas) { + targetAreas.forEach((areaId) => { + const expanded = expandAreaTarget( + this.hass, + areaId, + this.hass.devices, + this.hass.entities, + targetSelector + ); + targetEntities.push(...expanded.entities); + targetDevices.push(...expanded.devices); + }); + } + if (targetDevices.length) { + targetDevices.forEach((deviceId) => { + targetEntities.push( + ...expandDeviceTarget( + this.hass, + deviceId, + this.hass.entities, + targetSelector + ).entities + ); + }); + } + if (!targetEntities.length) { + return false; + } + if ( + targetEntities.some((entityId) => { + const entityState = this.hass.states[entityId]; + if (!entityState) { + return false; + } + if ( + filter!.supported_features?.some((feature) => + supportsFeature(entityState, feature) + ) + ) { + return true; + } + if ( + filter!.attribute && + Object.entries(filter!.attribute).some( + ([attribute, values]) => + attribute in entityState.attributes && + attributeFilter(values, entityState.attributes[attribute]) + ) + ) { + return true; + } + return false; + }) + ) { + return true; + } + return false; + } + protected render() { const serviceData = this._getServiceInfo( this._value?.service, @@ -235,6 +336,8 @@ export class HaServiceControl extends LitElement { serviceData?.fields.some((field) => showOptionalToggle(field)) ); + const filteredFields = this._filterFields(serviceData, this._value); + return html`` - : serviceData?.fields.map((dataField) => { + : filteredFields?.map((dataField) => { const showOptional = showOptionalToggle(dataField); return dataField.selector && (!dataField.advanced || diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index a7c5a79673..cec57a6d9a 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -1,3 +1,4 @@ +import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; // @ts-ignore import chipStyles from "@material/chips/dist/mdc.chips.min.css"; import "@material/mwc-button/mwc-button"; @@ -9,10 +10,9 @@ import { mdiSofa, mdiUnfoldMoreVertical, } from "@mdi/js"; -import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; import { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light"; import { HassEntity, HassServiceTarget } from "home-assistant-js-websocket"; -import { css, CSSResultGroup, html, LitElement, unsafeCSS, nothing } from "lit"; +import { css, CSSResultGroup, html, LitElement, nothing, unsafeCSS } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { ensureArray } from "../common/array/ensure-array"; diff --git a/src/data/selector.ts b/src/data/selector.ts index 0705e301df..8250172c80 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -3,8 +3,13 @@ import { ensureArray } from "../common/array/ensure-array"; import { computeStateDomain } from "../common/entity/compute_state_domain"; import { supportsFeature } from "../common/entity/supports-feature"; import { UiAction } from "../panels/lovelace/components/hui-action-editor"; -import type { DeviceRegistryEntry } from "./device_registry"; -import type { EntitySources } from "./entity_sources"; +import { HomeAssistant } from "../types"; +import { + DeviceRegistryEntry, + getDeviceIntegrationLookup, +} from "./device_registry"; +import { EntityRegistryDisplayEntry } from "./entity_registry"; +import { EntitySources } from "./entity_sources"; export type Selector = | ActionSelector @@ -361,10 +366,121 @@ export interface UiColorSelector { ui_color: {} | null; } +export const expandAreaTarget = ( + hass: HomeAssistant, + areaId: string, + devices: HomeAssistant["devices"], + entities: HomeAssistant["entities"], + targetSelector: TargetSelector, + entitySources?: EntitySources +) => { + const newEntities: string[] = []; + const newDevices: string[] = []; + Object.values(devices).forEach((device) => { + if ( + device.area_id === areaId && + deviceMeetsTargetSelector( + hass, + Object.values(entities), + device, + targetSelector, + entitySources + ) + ) { + newDevices.push(device.id); + } + }); + Object.values(entities).forEach((entity) => { + if ( + entity.area_id === areaId && + entityMeetsTargetSelector( + hass.states[entity.entity_id], + targetSelector, + entitySources + ) + ) { + newEntities.push(entity.entity_id); + } + }); + return { devices: newDevices, entities: newEntities }; +}; + +export const expandDeviceTarget = ( + hass: HomeAssistant, + deviceId: string, + entities: HomeAssistant["entities"], + targetSelector: TargetSelector, + entitySources?: EntitySources +) => { + const newEntities: string[] = []; + Object.values(entities).forEach((entity) => { + if ( + entity.device_id === deviceId && + entityMeetsTargetSelector( + hass.states[entity.entity_id], + targetSelector, + entitySources + ) + ) { + newEntities.push(entity.entity_id); + } + }); + return { entities: newEntities }; +}; + +const deviceMeetsTargetSelector = ( + hass: HomeAssistant, + entityRegistry: EntityRegistryDisplayEntry[], + device: DeviceRegistryEntry, + targetSelector: TargetSelector, + entitySources?: EntitySources +): boolean => { + const deviceIntegrationLookup = entitySources + ? getDeviceIntegrationLookup(entitySources, entityRegistry) + : undefined; + + if (targetSelector.target?.device) { + if ( + !ensureArray(targetSelector.target.device).some((filterDevice) => + filterSelectorDevices(filterDevice, device, deviceIntegrationLookup) + ) + ) { + return false; + } + } + if (targetSelector.target?.entity) { + const entities = entityRegistry.filter( + (reg) => reg.device_id === device.id + ); + return entities.some((entity) => { + const entityState = hass.states[entity.entity_id]; + return entityMeetsTargetSelector( + entityState, + targetSelector, + entitySources + ); + }); + } + return true; +}; + +const entityMeetsTargetSelector = ( + entity: HassEntity, + targetSelector: TargetSelector, + entitySources?: EntitySources +): boolean => { + if (targetSelector.target?.entity) { + return ensureArray(targetSelector.target!.entity).some((filterEntity) => + filterSelectorEntities(filterEntity, entity, entitySources) + ); + } + return true; +}; + export const filterSelectorDevices = ( filterDevice: DeviceSelectorFilter, device: DeviceRegistryEntry, - deviceIntegrationLookup: Record | undefined + deviceIntegrationLookup?: Record | undefined ): boolean => { const { manufacturer: filterManufacturer,