diff --git a/demo/src/stubs/area_registry.ts b/demo/src/stubs/area_registry.ts index b7d8e5a34b..59dd77ffe8 100644 --- a/demo/src/stubs/area_registry.ts +++ b/demo/src/stubs/area_registry.ts @@ -4,4 +4,11 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; export const mockAreaRegistry = ( hass: MockHomeAssistant, data: AreaRegistryEntry[] = [] -) => hass.mockWS("config/area_registry/list", () => data); +) => { + hass.mockWS("config/area_registry/list", () => data); + const areas = {}; + data.forEach((area) => { + areas[area.area_id] = area; + }); + hass.updateHass({ areas }); +}; diff --git a/demo/src/stubs/device_registry.ts b/demo/src/stubs/device_registry.ts index 28c47e4a96..d1ab8025ee 100644 --- a/demo/src/stubs/device_registry.ts +++ b/demo/src/stubs/device_registry.ts @@ -4,4 +4,11 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; export const mockDeviceRegistry = ( hass: MockHomeAssistant, data: DeviceRegistryEntry[] = [] -) => hass.mockWS("config/device_registry/list", () => data); +) => { + hass.mockWS("config/device_registry/list", () => data); + const devices = {}; + data.forEach((device) => { + devices[device.id] = device; + }); + hass.updateHass({ devices }); +}; diff --git a/demo/src/stubs/floor_registry.ts b/demo/src/stubs/floor_registry.ts new file mode 100644 index 0000000000..c962f07a5c --- /dev/null +++ b/demo/src/stubs/floor_registry.ts @@ -0,0 +1,7 @@ +import { FloorRegistryEntry } from "../../../src/data/floor_registry"; +import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; + +export const mockFloorRegistry = ( + hass: MockHomeAssistant, + data: FloorRegistryEntry[] = [] +) => hass.mockWS("config/floor_registry/list", () => data); diff --git a/demo/src/stubs/label_registry.ts b/demo/src/stubs/label_registry.ts new file mode 100644 index 0000000000..27ca8fdc8e --- /dev/null +++ b/demo/src/stubs/label_registry.ts @@ -0,0 +1,7 @@ +import { LabelRegistryEntry } from "../../../src/data/label_registry"; +import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; + +export const mockLabelRegistry = ( + hass: MockHomeAssistant, + data: LabelRegistryEntry[] = [] +) => hass.mockWS("config/label_registry/list", () => data); diff --git a/gallery/src/pages/components/ha-selector.ts b/gallery/src/pages/components/ha-selector.ts index 15e2aae1ad..3824d9bb18 100644 --- a/gallery/src/pages/components/ha-selector.ts +++ b/gallery/src/pages/components/ha-selector.ts @@ -17,6 +17,10 @@ import { provideHass } from "../../../../src/fake_data/provide_hass"; import { ProvideHassElement } from "../../../../src/mixins/provide-hass-lit-mixin"; import type { HomeAssistant } from "../../../../src/types"; import "../../components/demo-black-white-row"; +import { FloorRegistryEntry } from "../../../../src/data/floor_registry"; +import { LabelRegistryEntry } from "../../../../src/data/label_registry"; +import { mockFloorRegistry } from "../../../../demo/src/stubs/floor_registry"; +import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry"; const ENTITIES = [ getEntity("alarm_control_panel", "alarm", "disarmed", { @@ -100,7 +104,7 @@ const DEVICES = [ const AREAS: AreaRegistryEntry[] = [ { area_id: "backyard", - floor_id: null, + floor_id: "ground", name: "Backyard", icon: null, picture: null, @@ -109,7 +113,7 @@ const AREAS: AreaRegistryEntry[] = [ }, { area_id: "bedroom", - floor_id: null, + floor_id: "first", name: "Bedroom", icon: "mdi:bed", picture: null, @@ -118,7 +122,7 @@ const AREAS: AreaRegistryEntry[] = [ }, { area_id: "livingroom", - floor_id: null, + floor_id: "ground", name: "Livingroom", icon: "mdi:sofa", picture: null, @@ -127,6 +131,45 @@ const AREAS: AreaRegistryEntry[] = [ }, ]; +const FLOORS: FloorRegistryEntry[] = [ + { + floor_id: "ground", + name: "Ground floor", + level: 0, + icon: null, + aliases: [], + }, + { + floor_id: "first", + name: "First floor", + level: 1, + icon: "mdi:numeric-1", + aliases: [], + }, + { + floor_id: "second", + name: "Second floor", + level: 2, + icon: "mdi:numeric-2", + aliases: [], + }, +]; + +const LABELS: LabelRegistryEntry[] = [ + { + label_id: "energy", + name: "Energy", + icon: null, + color: "yellow", + }, + { + label_id: "entertainment", + name: "Entertainment", + icon: "mdi:popcorn", + color: "blue", + }, +]; + const SCHEMAS: { name: string; input: Record; @@ -134,7 +177,12 @@ const SCHEMAS: { { name: "One of each", input: { + label: { name: "Label", selector: { label: {} } }, + floor: { name: "Floor", selector: { floor: {} } }, + area: { name: "Area", selector: { area: {} } }, + device: { name: "Device", selector: { device: {} } }, entity: { name: "Entity", selector: { entity: {} } }, + target: { name: "Target", selector: { target: {} } }, state: { name: "State", selector: { state: { entity_id: "alarm_control_panel.alarm" } }, @@ -143,15 +191,12 @@ const SCHEMAS: { name: "Attribute", selector: { attribute: { entity_id: "" } }, }, - device: { name: "Device", selector: { device: {} } }, config_entry: { name: "Integration", selector: { config_entry: {} }, }, duration: { name: "Duration", selector: { duration: {} } }, addon: { name: "Addon", selector: { addon: {} } }, - area: { name: "Area", selector: { area: {} } }, - target: { name: "Target", selector: { target: {} } }, number_box: { name: "Number Box", selector: { @@ -300,6 +345,8 @@ const SCHEMAS: { entity: { name: "Entity", selector: { entity: { multiple: true } } }, device: { name: "Device", selector: { device: { multiple: true } } }, area: { name: "Area", selector: { area: { multiple: true } } }, + floor: { name: "Floor", selector: { floor: { multiple: true } } }, + label: { name: "Label", selector: { label: { multiple: true } } }, select: { name: "Select Multiple", selector: { @@ -356,6 +403,8 @@ class DemoHaSelector extends LitElement implements ProvideHassElement { mockDeviceRegistry(hass, DEVICES); mockConfigEntries(hass); mockAreaRegistry(hass, AREAS); + mockFloorRegistry(hass, FLOORS); + mockLabelRegistry(hass, LABELS); mockHassioSupervisor(hass); hass.mockWS("auth/sign_path", (params) => params); hass.mockWS("media_player/browse_media", this._browseMedia); diff --git a/src/components/ha-floor-picker.ts b/src/components/ha-floor-picker.ts index 59886ffa29..9ac37cf746 100644 --- a/src/components/ha-floor-picker.ts +++ b/src/components/ha-floor-picker.ts @@ -274,7 +274,7 @@ export class HaFloorPicker extends SubscribeMixin(LitElement) { if (areaIds) { const floorAreaLookup = getFloorAreaLookup(areas); outputFloors = outputFloors.filter((floor) => - floorAreaLookup[floor.floor_id].some((area) => + floorAreaLookup[floor.floor_id]?.some((area) => areaIds!.includes(area.area_id) ) ); diff --git a/src/components/ha-floors-picker.ts b/src/components/ha-floors-picker.ts new file mode 100644 index 0000000000..e5f0e39655 --- /dev/null +++ b/src/components/ha-floors-picker.ts @@ -0,0 +1,169 @@ +import { HassEntity } from "home-assistant-js-websocket"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../common/dom/fire_event"; +import { SubscribeMixin } from "../mixins/subscribe-mixin"; +import type { HomeAssistant } from "../types"; +import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; +import "./ha-floor-picker"; + +@customElement("ha-floors-picker") +export class HaFloorsPicker extends SubscribeMixin(LitElement) { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public label?: string; + + @property({ type: Array }) public value?: string[]; + + @property() public helper?: string; + + @property() public placeholder?: string; + + @property({ type: Boolean, attribute: "no-add" }) + public noAdd = false; + + /** + * Show only floors with entities from specific domains. + * @type {Array} + * @attr include-domains + */ + @property({ type: Array, attribute: "include-domains" }) + public includeDomains?: string[]; + + /** + * Show no floors with entities of these domains. + * @type {Array} + * @attr exclude-domains + */ + @property({ type: Array, attribute: "exclude-domains" }) + public excludeDomains?: string[]; + + /** + * Show only floors with entities of these device classes. + * @type {Array} + * @attr include-device-classes + */ + @property({ type: Array, attribute: "include-device-classes" }) + public includeDeviceClasses?: string[]; + + @property({ attribute: false }) + public deviceFilter?: HaDevicePickerDeviceFilterFunc; + + @property({ attribute: false }) + public entityFilter?: (entity: HassEntity) => boolean; + + @property({ attribute: "picked-floor-label" }) + public pickedFloorLabel?: string; + + @property({ attribute: "pick-floor-label" }) + public pickFloorLabel?: string; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = false; + + protected render() { + if (!this.hass) { + return nothing; + } + + const currentFloors = this._currentFloors; + return html` + ${currentFloors.map( + (floor) => html` +
+ +
+ ` + )} +
+ +
+ `; + } + + private get _currentFloors(): string[] { + return this.value || []; + } + + private async _updateFloors(floors) { + this.value = floors; + + fireEvent(this, "value-changed", { + value: floors, + }); + } + + private _floorChanged(ev: CustomEvent) { + ev.stopPropagation(); + const curValue = (ev.currentTarget as any).curValue; + const newValue = ev.detail.value; + if (newValue === curValue) { + return; + } + const currentFloors = this._currentFloors; + if (!newValue || currentFloors.includes(newValue)) { + this._updateFloors(currentFloors.filter((ent) => ent !== curValue)); + return; + } + this._updateFloors( + currentFloors.map((ent) => (ent === curValue ? newValue : ent)) + ); + } + + private _addFloor(ev: CustomEvent) { + ev.stopPropagation(); + + const toAdd = ev.detail.value; + if (!toAdd) { + return; + } + (ev.currentTarget as any).value = ""; + const currentFloors = this._currentFloors; + if (currentFloors.includes(toAdd)) { + return; + } + + this._updateFloors([...currentFloors, toAdd]); + } + + static override styles = css` + div { + margin-top: 8px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-floors-picker": HaFloorsPicker; + } +} diff --git a/src/components/ha-selector/ha-selector-area.ts b/src/components/ha-selector/ha-selector-area.ts index e746314231..7690c387ad 100644 --- a/src/components/ha-selector/ha-selector-area.ts +++ b/src/components/ha-selector/ha-selector-area.ts @@ -87,8 +87,12 @@ export class HaAreaSelector extends LitElement { .label=${this.label} .helper=${this.helper} no-add - .deviceFilter=${this._filterDevices} - .entityFilter=${this._filterEntities} + .deviceFilter=${this.selector.area?.device + ? this._filterDevices + : undefined} + .entityFilter=${this.selector.area?.entity + ? this._filterEntities + : undefined} .disabled=${this.disabled} .required=${this.required} > @@ -102,8 +106,12 @@ export class HaAreaSelector extends LitElement { .helper=${this.helper} .pickAreaLabel=${this.label} no-add - .deviceFilter=${this._filterDevices} - .entityFilter=${this._filterEntities} + .deviceFilter=${this.selector.area?.device + ? this._filterDevices + : undefined} + .entityFilter=${this.selector.area?.entity + ? this._filterEntities + : undefined} .disabled=${this.disabled} .required=${this.required} > diff --git a/src/components/ha-selector/ha-selector-floor.ts b/src/components/ha-selector/ha-selector-floor.ts new file mode 100644 index 0000000000..eac63f414e --- /dev/null +++ b/src/components/ha-selector/ha-selector-floor.ts @@ -0,0 +1,153 @@ +import { HassEntity } from "home-assistant-js-websocket"; +import { html, LitElement, PropertyValues, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { ensureArray } from "../../common/array/ensure-array"; +import type { DeviceRegistryEntry } from "../../data/device_registry"; +import { getDeviceIntegrationLookup } from "../../data/device_registry"; +import { fireEvent } from "../../common/dom/fire_event"; +import { + EntitySources, + fetchEntitySourcesWithCache, +} from "../../data/entity_sources"; +import type { FloorSelector } from "../../data/selector"; +import { + filterSelectorDevices, + filterSelectorEntities, +} from "../../data/selector"; +import { HomeAssistant } from "../../types"; +import "../ha-floor-picker"; +import "../ha-floors-picker"; + +@customElement("ha-selector-floor") +export class HaFloorSelector extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public selector!: FloorSelector; + + @property() public value?: any; + + @property() public label?: string; + + @property() public helper?: string; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = true; + + @state() private _entitySources?: EntitySources; + + private _deviceIntegrationLookup = memoizeOne(getDeviceIntegrationLookup); + + private _hasIntegration(selector: FloorSelector) { + return ( + (selector.floor?.entity && + ensureArray(selector.floor.entity).some( + (filter) => filter.integration + )) || + (selector.floor?.device && + ensureArray(selector.floor.device).some((device) => device.integration)) + ); + } + + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("selector") && this.value !== undefined) { + if (this.selector.floor?.multiple && !Array.isArray(this.value)) { + this.value = [this.value]; + fireEvent(this, "value-changed", { value: this.value }); + } else if (!this.selector.floor?.multiple && Array.isArray(this.value)) { + this.value = this.value[0]; + fireEvent(this, "value-changed", { value: this.value }); + } + } + } + + protected updated(changedProperties: PropertyValues): void { + if ( + changedProperties.has("selector") && + this._hasIntegration(this.selector) && + !this._entitySources + ) { + fetchEntitySourcesWithCache(this.hass).then((sources) => { + this._entitySources = sources; + }); + } + } + + protected render() { + if (this._hasIntegration(this.selector) && !this._entitySources) { + return nothing; + } + + if (!this.selector.floor?.multiple) { + return html` + + `; + } + + return html` + + `; + } + + private _filterEntities = (entity: HassEntity): boolean => { + if (!this.selector.floor?.entity) { + return true; + } + + return ensureArray(this.selector.floor.entity).some((filter) => + filterSelectorEntities(filter, entity, this._entitySources) + ); + }; + + private _filterDevices = (device: DeviceRegistryEntry): boolean => { + if (!this.selector.floor?.device) { + return true; + } + + const deviceIntegrations = this._entitySources + ? this._deviceIntegrationLookup( + this._entitySources, + Object.values(this.hass.entities) + ) + : undefined; + + return ensureArray(this.selector.floor.device).some((filter) => + filterSelectorDevices(filter, device, deviceIntegrations) + ); + }; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-floor": HaFloorSelector; + } +} diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts index 5ab02782c1..e622721b6d 100644 --- a/src/components/ha-selector/ha-selector.ts +++ b/src/components/ha-selector/ha-selector.ts @@ -30,6 +30,7 @@ const LOAD_ELEMENTS = { entity: () => import("./ha-selector-entity"), statistic: () => import("./ha-selector-statistic"), file: () => import("./ha-selector-file"), + floor: () => import("./ha-selector-floor"), label: () => import("./ha-selector-label"), language: () => import("./ha-selector-language"), navigation: () => import("./ha-selector-navigation"), diff --git a/src/data/selector.ts b/src/data/selector.ts index 442fba220f..3abb7ae6fc 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -31,6 +31,7 @@ export type Selector = | DateSelector | DateTimeSelector | DeviceSelector + | FloorSelector | LegacyDeviceSelector | DurationSelector | EntitySelector @@ -170,6 +171,14 @@ export interface DeviceSelector { } | null; } +export interface FloorSelector { + floor: { + entity?: EntitySelectorFilter | readonly EntitySelectorFilter[]; + device?: DeviceSelectorFilter | readonly DeviceSelectorFilter[]; + multiple?: boolean; + } | null; +} + export interface LegacyDeviceSelector { device: DeviceSelector["device"] & { /**